aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Herramientas que el agente puede pedir: especificaciones para el modelo y análisis tipado de sus argumentos.
//! La ejecución la hace el editor, que es quien tiene los buffers, el LSP y la terminal.

use crate::ai::types::ToolSpec;
use serde_json::{Value, json};

/// Qué tan peligrosa es una herramienta: decide si pide confirmación.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Risk {
    /// Solo lee: se ejecuta sin preguntar.
    Read,
    /// Cambia archivos o ejecuta comandos: pide confirmación.
    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>,
    },
    /// Uno o varios reemplazos en el mismo archivo, todos o ninguno (ver `edits`).
    EditFile {
        path: String,
        edits: Vec<crate::ai::edits::Edit>,
    },
    /// Crea o sobrescribe el archivo entero (como `write` de pi).
    WriteFile {
        path: String,
        content: String,
    },
    /// Archivos por glob (`**/*.rs`) o por trozo del camino, respetando `.gitignore`.
    FindFiles {
        pattern: String,
        path: Option<String>,
    },
    Terminal {
        command: String,
    },
    /// Delega una tarea de solo lectura en un subagente configurado (otro modelo, quizá de otro proveedor).
    Task {
        agent: String,
        prompt: String,
    },
    /// Memoria del código del editor (`code_memory`): la acción y sus argumentos van en `input`.
    Memory {
        input: Value,
    },
    /// Carga las instrucciones de una skill instalada para seguirlas (`skill`).
    Skill {
        name: String,
    },
    /// Pantalla, ratón y teclado del computador (`computer`).
    Computer {
        action: crate::ai::computer::ComputerAction,
    },
    /// Navegador del sistema (`browser`): abrir, leer o buscar sin controlar la pantalla.
    Browser {
        action: crate::ai::browser::BrowserAction,
    },
    /// Herramienta de un servidor MCP (`mcp__servidor__herramienta`).
    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",
        }
    }

    /// Resumen de una línea para mostrar en el panel y en la confirmación.
    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}")
            }
        }
    }

    /// Interpreta la llamada del modelo; el error explica qué argumento falla para devolvérselo al modelo.
    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 }),
    }
}

/// Herramientas que se ofrecen al modelo.
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(),
    ]
}

/// Herramienta `task` con los subagentes configurados `(nombre, para qué sirve)`; sin subagentes no se ofrece.
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);
        }
    }
}