//! Herramientas que el modelo puede invocar. //! //! El punto de extensión del asistente. Una herramienta es un objeto que se //! describe a sí mismo en JSON Schema y sabe ejecutarse; el registro las //! traduce al formato `tools` de la API de chat y despacha las llamadas que //! vuelven. Añadir una capacidad nueva es implementar el trait y registrarla, //! sin tocar el orquestador. use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; use serde_json::{json, Value}; use crate::config::ToolsConfig; use crate::error::{Error, Result}; /// Lo que el modelo puede pedir que se haga. pub trait Tool: Send + Sync { /// Identificador que usa el modelo. En minúsculas y sin espacios. fn name(&self) -> &str; /// Para qué sirve. Lo lee el modelo, así que se escribe para él: concreto /// y en la lengua de la conversación. fn description(&self) -> &str; /// JSON Schema de los argumentos. fn parameters(&self) -> Value; /// Ejecuta y devuelve el texto que se le entrega al modelo como resultado. fn call(&self, args: &Value) -> Result; /// `true` si la herramienta cambia algo fuera del proceso. El orquestador /// lo anuncia en voz alta antes de ejecutarla. fn is_side_effecting(&self) -> bool { false } /// Frase que se pronuncia nada más empezar a ejecutarla. /// /// Existe por una razón de trato, no de adorno: una búsqueda son unos /// 2,5 s, la cámara casi 3, y a eso hay que sumarle las dos pasadas del /// modelo. Seis segundos de silencio absoluto se leen como que el /// asistente se ha colgado. Devuelve `None` la herramienta que responda /// al instante, donde el acuse molestaría más que ayudar. fn acknowledgement(&self) -> Option<&str> { None } } /// Una llamada tal y como la pide el modelo. #[derive(Debug, Clone)] pub struct ToolCall { pub id: String, pub name: String, /// Argumentos en JSON, aún sin validar. pub arguments: String, } /// Resultado de ejecutarla. #[derive(Debug, Clone)] pub struct ToolOutcome { pub id: String, pub name: String, pub ok: bool, pub output: String, pub took: Duration, } #[derive(Default, Clone)] pub struct ToolRegistry { tools: BTreeMap>, } impl ToolRegistry { pub fn new() -> Self { Self::default() } /// Monta el juego de herramientas que pide la configuración. pub fn from_config(config: &ToolsConfig) -> Self { let mut registry = Self::new(); if !config.enabled { return registry; } registry.register(Arc::new(builtin::Clock)); if config.shell { registry.register(Arc::new(builtin::Shell::new(config))); } registry } pub fn register(&mut self, tool: Arc) { self.tools.insert(tool.name().to_string(), tool); } pub fn is_empty(&self) -> bool { self.tools.is_empty() } pub fn names(&self) -> Vec<&str> { self.tools.keys().map(String::as_str).collect() } pub fn get(&self, name: &str) -> Option<&Arc> { self.tools.get(name) } /// Descripción en el formato `tools` de la API de chat de OpenAI, que es /// el que habla llama-server. pub fn schema(&self) -> Value { Value::Array( self.tools .values() .map(|tool| { json!({ "type": "function", "function": { "name": tool.name(), "description": tool.description(), "parameters": tool.parameters(), } }) }) .collect(), ) } /// Ejecuta una llamada. Nunca propaga el error hacia arriba: un fallo de /// herramienta se le devuelve al modelo como texto para que lo explique o /// lo reintente, en vez de tumbar el turno. pub fn dispatch(&self, call: &ToolCall) -> ToolOutcome { let started = std::time::Instant::now(); let result = match self.tools.get(&call.name) { None => Err(Error::Tool { tool: call.name.clone(), message: format!( "no existe esa herramienta; disponibles: {}", self.names().join(", ") ), }), Some(tool) => serde_json::from_str::(&call.arguments) .or_else(|_| { // Los modelos pequeños mandan a veces la cadena vacía en // lugar de «{}» cuando la función no lleva argumentos. if call.arguments.trim().is_empty() { Ok(json!({})) } else { Err(Error::Tool { tool: call.name.clone(), message: format!("argumentos JSON inválidos: {}", call.arguments), }) } }) .and_then(|args| tool.call(&args)), }; let took = started.elapsed(); match result { Ok(output) => ToolOutcome { id: call.id.clone(), name: call.name.clone(), ok: true, output, took, }, Err(err) => ToolOutcome { id: call.id.clone(), name: call.name.clone(), ok: false, output: format!("error: {err}"), took, }, } } } pub mod builtin { use super::*; use std::path::Path; use std::process::Command; /// Fecha y hora locales. Existe porque el modelo no las sabe y las inventa /// con aplomo, y de paso sirve de ejemplo mínimo de herramienta. pub struct Clock; impl Tool for Clock { fn name(&self) -> &str { "hora_actual" } fn description(&self) -> &str { "Devuelve la fecha y la hora locales del sistema. Úsala siempre que \ te pregunten qué hora o qué día es, en vez de suponerlo." } fn parameters(&self) -> Value { json!({ "type": "object", "properties": {}, "required": [] }) } fn call(&self, _args: &Value) -> Result { // `date` evita arrastrar una dependencia de calendario entera para // una sola llamada, y respeta la zona horaria del sistema. Se le // piden campos numéricos y los nombres se ponen aquí: `%A` y `%B` // salen en el idioma de la locale, que en esta máquina es inglés, // y el asistente acabaría diciendo «Sunday 6 de September». let out = Command::new("date") .arg("+%w %-d %-m %Y %H:%M") .output() .map_err(|e| Error::Tool { tool: "hora_actual".into(), message: e.to_string(), })?; let raw = String::from_utf8_lossy(&out.stdout); format_spanish_date(raw.trim()).ok_or_else(|| Error::Tool { tool: "hora_actual".into(), message: format!("«date» devolvió algo inesperado: {raw:?}"), }) } } pub(super) const DIAS: [&str; 7] = [ "domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado", ]; const MESES: [&str; 12] = [ "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre", ]; /// Compone «domingo 6 de septiembre de 2026, 19:13» a partir de /// «0 6 9 2026 19:13». pub(super) fn format_spanish_date(raw: &str) -> Option { let mut fields = raw.split_whitespace(); let weekday: usize = fields.next()?.parse().ok()?; let day: u32 = fields.next()?.parse().ok()?; let month: usize = fields.next()?.parse().ok()?; let year: i32 = fields.next()?.parse().ok()?; let time = fields.next()?; Some(format!( "{} {day} de {} de {year}, {time}", DIAS.get(weekday)?, MESES.get(month.checked_sub(1)?)? )) } /// Ejecución de órdenes del sistema, restringida por lista blanca. /// /// Es la puerta por la que el asistente pasa de hablar a actuar, y también /// la más delicada del proyecto: lo que llega aquí viene, en última /// instancia, de lo que se oye por el micrófono. De ahí las cuatro /// barreras: apagada por defecto, lista blanca sobre el ejecutable, /// argumentos sin shell que los interprete, y un plazo máximo. pub struct Shell { allowlist: Vec, timeout: Duration, dry_run: bool, working_dir: String, } impl Shell { pub fn new(config: &ToolsConfig) -> Self { Self { allowlist: config.shell_allowlist.clone(), timeout: Duration::from_secs(config.shell_timeout_secs), dry_run: config.shell_dry_run, working_dir: config.shell_working_dir.clone(), } } fn permitted(&self, program: &str) -> bool { // Se compara sólo el nombre del ejecutable: dejar pasar rutas // completas invitaría a colar «/tmp/ls». !program.contains('/') && self.allowlist.iter().any(|a| a == program) } } impl Tool for Shell { fn name(&self) -> &str { "ejecutar_comando" } fn description(&self) -> &str { "Ejecuta una orden del sistema de una lista autorizada y devuelve su \ salida. Indica el programa y sus argumentos por separado. Si la \ orden no está autorizada, se rechaza." } fn parameters(&self) -> Value { json!({ "type": "object", "properties": { "programa": { "type": "string", "description": "Nombre del ejecutable, sin ruta. Por ejemplo: date" }, "argumentos": { "type": "array", "items": { "type": "string" }, "description": "Argumentos, uno por elemento. No uses tuberías ni redirecciones." } }, "required": ["programa"] }) } fn is_side_effecting(&self) -> bool { true } fn call(&self, args: &Value) -> Result { let fail = |message: String| Error::Tool { tool: "ejecutar_comando".into(), message, }; let program = args .get("programa") .and_then(Value::as_str) .ok_or_else(|| fail("falta «programa»".into()))?; let arguments: Vec = match args.get("argumentos") { None | Some(Value::Null) => Vec::new(), Some(Value::Array(items)) => items .iter() .map(|v| match v { Value::String(s) => Ok(s.clone()), other => Ok(other.to_string()), }) .collect::>()?, // Algunos modelos mandan los argumentos en una sola cadena. Some(Value::String(s)) => s.split_whitespace().map(String::from).collect(), Some(other) => return Err(fail(format!("«argumentos» inválido: {other}"))), }; if !self.permitted(program) { return Err(fail(format!( "«{program}» no está autorizada. Autorizadas: {}", self.allowlist.join(", ") ))); } let rendered = format!("{program} {}", arguments.join(" ")); if self.dry_run { return Ok(format!("[simulación] no se ejecutó: {}", rendered.trim())); } // Sin shell de por medio: los argumentos van al `execve` tal cual, // así que un «; rm -rf /» es un argumento literal, no otra orden. let working_dir = (!self.working_dir.is_empty()).then(|| Path::new(&self.working_dir)); let output = crate::proc::run(program, &arguments, self.timeout, working_dir) .map_err(|e| fail(e.to_string()))?; let stdout = String::from_utf8_lossy(&output.stdout); let mut body = stdout.trim().to_string(); if body.is_empty() { body = output.stderr.trim().to_string(); } // La salida se va a leer en voz alta: pasado cierto punto sólo // sirve para aburrir a quien escucha. const MAX: usize = 2000; if body.chars().count() > MAX { body = body.chars().take(MAX).collect::() + "… (salida recortada)"; } if !output.success() { return Ok(format!( "la orden terminó con código {}: {body}", output.status.unwrap_or(-1) )); } Ok(if body.is_empty() { "la orden terminó correctamente y no imprimió nada".into() } else { body }) } } } #[cfg(test)] mod tests { use super::builtin::{format_spanish_date, Shell}; use super::*; #[test] fn la_fecha_se_compone_en_espanol_sin_depender_de_la_locale() { assert_eq!( format_spanish_date("0 6 9 2026 19:13").unwrap(), "domingo 6 de septiembre de 2026, 19:13" ); assert_eq!( format_spanish_date("3 1 1 2027 00:05").unwrap(), "miércoles 1 de enero de 2027, 00:05" ); } #[test] fn una_salida_de_date_inesperada_no_provoca_un_panico() { assert!(format_spanish_date("").is_none()); assert!( format_spanish_date("9 6 9 2026 19:13").is_none(), "día de semana fuera de rango" ); assert!( format_spanish_date("0 6 13 2026 19:13").is_none(), "mes fuera de rango" ); assert!( format_spanish_date("0 6 0 2026 19:13").is_none(), "mes cero" ); } #[test] fn la_herramienta_de_hora_devuelve_algo_en_espanol() { let out = builtin::Clock.call(&json!({})).unwrap(); assert!( super::builtin::DIAS.iter().any(|d| out.starts_with(d)), "esperaba un día en español, salió «{out}»" ); } fn shell_config() -> ToolsConfig { ToolsConfig { enabled: true, dedicated_prompt: true, spoken_ack: true, shell: true, shell_allowlist: vec!["echo".into(), "sleep".into()], shell_timeout_secs: 1, shell_dry_run: false, shell_working_dir: String::new(), } } #[test] fn la_lista_blanca_deja_pasar_lo_autorizado() { let shell = Shell::new(&shell_config()); let out = shell .call(&json!({ "programa": "echo", "argumentos": ["hola"] })) .unwrap(); assert_eq!(out, "hola"); } #[test] fn lo_no_autorizado_se_rechaza() { let shell = Shell::new(&shell_config()); let err = shell.call(&json!({ "programa": "rm", "argumentos": ["-rf", "/"] })); assert!( err.is_err(), "«rm» no estaba en la lista y debió rechazarse" ); } #[test] fn una_ruta_absoluta_no_esquiva_la_lista_blanca() { let shell = Shell::new(&shell_config()); assert!( shell.call(&json!({ "programa": "/bin/echo" })).is_err(), "«/bin/echo» debe rechazarse: si no, basta con dar una ruta para colar cualquier binario" ); } #[test] fn no_hay_shell_que_interprete_los_metacaracteres() { // Si esto llegara a una shell, se ejecutarían dos órdenes. let shell = Shell::new(&shell_config()); let out = shell .call(&json!({ "programa": "echo", "argumentos": ["a; echo b"] })) .unwrap(); assert_eq!( out, "a; echo b", "el punto y coma debe ser texto, no un separador" ); } #[test] fn una_orden_colgada_se_mata_al_vencer_el_plazo() { let shell = Shell::new(&shell_config()); let err = shell .call(&json!({ "programa": "sleep", "argumentos": ["30"] })) .unwrap_err(); let err = err.to_string(); assert!(err.contains("tardó más de"), "{err}"); assert!( err.contains("sleep"), "el error debe decir qué orden se colgó: {err}" ); } #[test] fn la_simulacion_no_ejecuta_nada() { let mut config = shell_config(); config.shell_dry_run = true; let shell = Shell::new(&config); let out = shell .call(&json!({ "programa": "echo", "argumentos": ["x"] })) .unwrap(); assert!(out.starts_with("[simulación]"), "{out}"); } #[test] fn la_shell_no_se_registra_si_la_configuracion_no_la_pide() { let registry = ToolRegistry::from_config(&ToolsConfig::default()); assert!(registry.get("ejecutar_comando").is_none()); assert!(registry.get("hora_actual").is_some()); } #[test] fn una_herramienta_inexistente_devuelve_error_al_modelo_sin_romper_el_turno() { let registry = ToolRegistry::from_config(&ToolsConfig::default()); let outcome = registry.dispatch(&ToolCall { id: "1".into(), name: "no_existe".into(), arguments: "{}".into(), }); assert!(!outcome.ok); assert!(outcome.output.contains("hora_actual"), "{}", outcome.output); } #[test] fn los_argumentos_vacios_valen_como_objeto_vacio() { let registry = ToolRegistry::from_config(&ToolsConfig::default()); let outcome = registry.dispatch(&ToolCall { id: "1".into(), name: "hora_actual".into(), arguments: "".into(), }); assert!(outcome.ok, "{}", outcome.output); } #[test] fn el_esquema_sale_en_el_formato_de_la_api_de_chat() { let registry = ToolRegistry::from_config(&ToolsConfig::default()); let schema = registry.schema(); let first = &schema.as_array().unwrap()[0]; assert_eq!(first["type"], "function"); assert_eq!(first["function"]["name"], "hora_actual"); } }