//! Tools the model can call. //! //! The assistant's extension point. A tool is an object that describes itself //! in JSON Schema and knows how to run; the registry translates them to the //! chat API `tools` format and dispatches the calls that come back. Adding a //! new capability means implementing the trait and registering it, without //! touching the orchestrator. 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}; /// Something the model can ask to be done. pub trait Tool: Send + Sync { /// Identifier the model uses. Lowercase, no spaces. fn name(&self) -> &str; /// What it is for. The model reads it, so it is written for the model: /// concrete and in the conversation language. fn description(&self) -> &str; /// JSON Schema of the arguments. fn parameters(&self) -> Value; /// Runs and returns the text handed to the model as the result. fn call(&self, args: &Value) -> Result; /// `true` if the tool changes something outside the process. The /// orchestrator announces it aloud before running it. fn is_side_effecting(&self) -> bool { false } /// Sentence spoken right when it starts running. /// /// It exists for the sake of the conversation, not decoration: a search takes /// about 2.5 s, the camera almost 3, and the two model passes come on top. /// Six seconds of total silence read as the assistant having hung. A tool /// that answers instantly returns `None`, where the acknowledgement would /// annoy more than help. fn acknowledgement(&self) -> Option<&str> { None } } /// A call as the model requests it. #[derive(Debug, Clone)] pub struct ToolCall { pub id: String, pub name: String, /// JSON arguments, not validated yet. pub arguments: String, } /// Result of running it. #[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() } /// Builds the tool set the configuration asks for. 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) } /// Description in the OpenAI chat API `tools` format, which is what /// llama-server speaks. 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(), ) } /// Runs a call. It never propagates the error upwards: a tool failure is /// handed back to the model as text so it can explain it or retry, instead /// of bringing the turn down. 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(|_| { // Small models sometimes send an empty string instead of «{}» // when the function takes no arguments. 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; /// Local date and time. It exists because the model does not know them and /// confidently makes them up, and it doubles as a minimal tool example. 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` avoids pulling in a whole calendar dependency for a single // call, and it honours the system time zone. It is asked for numeric // fields and the names are filled in here: `%A` and `%B` come out in // the locale language, which on this machine is English, and the // assistant would end up saying «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 WEEKDAYS: [&str; 7] = [ "domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado", ]; const MONTHS: [&str; 12] = [ "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre", ]; /// Builds «domingo 6 de septiembre de 2026, 19:13» from /// «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}", WEEKDAYS.get(weekday)?, MONTHS.get(month.checked_sub(1)?)? )) } /// System command execution, restricted by an allowlist. /// /// It is the door through which the assistant goes from talking to acting, /// and also the most delicate one in the project: whatever arrives here comes, /// ultimately, from what the microphone hears. Hence four barriers: off by /// default, an allowlist on the executable, arguments with no shell to /// interpret them, and a deadline. 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 { // Only the executable name is compared: letting full paths through // would invite sneaking in «/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::>()?, // Some models send the arguments as a single string. 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())); } // No shell in between: the arguments go to `execve` as they are, // so a «; rm -rf /» is a literal argument, not another command. 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(); } // The output will be read aloud: past a certain point it only // bores the listener. 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 date_is_built_in_spanish_regardless_of_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 unexpected_date_output_does_not_panic() { assert!(format_spanish_date("").is_none()); assert!( format_spanish_date("9 6 9 2026 19:13").is_none(), "weekday out of range" ); assert!( format_spanish_date("0 6 13 2026 19:13").is_none(), "month out of range" ); assert!( format_spanish_date("0 6 0 2026 19:13").is_none(), "month zero" ); } #[test] fn time_tool_returns_spanish_text() { let out = builtin::Clock.call(&json!({})).unwrap(); assert!( super::builtin::WEEKDAYS.iter().any(|d| out.starts_with(d)), "expected a Spanish weekday, got «{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 allowlist_lets_allowed_commands_through() { let shell = Shell::new(&shell_config()); let out = shell .call(&json!({ "programa": "echo", "argumentos": ["hola"] })) .unwrap(); assert_eq!(out, "hola"); } #[test] fn unauthorized_commands_are_rejected() { let shell = Shell::new(&shell_config()); let err = shell.call(&json!({ "programa": "rm", "argumentos": ["-rf", "/"] })); assert!( err.is_err(), "«rm» was not in the list and should have been rejected" ); } #[test] fn absolute_path_does_not_bypass_the_allowlist() { let shell = Shell::new(&shell_config()); assert!( shell.call(&json!({ "programa": "/bin/echo" })).is_err(), "«/bin/echo» must be rejected: otherwise any binary gets in by giving a path" ); } #[test] fn no_shell_interprets_the_metacharacters() { // If this reached a shell, two commands would run. let shell = Shell::new(&shell_config()); let out = shell .call(&json!({ "programa": "echo", "argumentos": ["a; echo b"] })) .unwrap(); assert_eq!( out, "a; echo b", "the semicolon must be text, not a separator" ); } #[test] fn hung_command_is_killed_at_the_deadline() { 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"), "the error must say which command hung: {err}" ); } #[test] fn dry_run_executes_nothing() { 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 shell_is_not_registered_unless_configured() { let registry = ToolRegistry::from_config(&ToolsConfig::default()); assert!(registry.get("ejecutar_comando").is_none()); assert!(registry.get("hora_actual").is_some()); } #[test] fn unknown_tool_returns_an_error_to_the_model_without_breaking_the_turn() { 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 empty_arguments_count_as_an_empty_object() { 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 schema_uses_the_chat_api_format() { 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"); } }