diff options
| author | elvis <elvis@claros.ar> | 2026-09-06 19:21:16 -0300 |
|---|---|---|
| committer | elvis <elvis@claros.ar> | 2026-09-06 19:23:37 -0300 |
| commit | f8f98e83481e7376a235fb305a095552433f79ab (patch) | |
| tree | 6d0ecddb26bef8430a086431de40f8ce9b1039e6 /crates/asist-core/src/tools.rs | |
| download | asist-p-f8f98e83481e7376a235fb305a095552433f79ab.tar.gz asist-p-f8f98e83481e7376a235fb305a095552433f79ab.zip | |
Local voice assistant on top of Canary, llama.cpp and qwentts
Pipeline en Rust de hilos y canales que une los tres motores: el micrófono
alimenta un segmentador con VAD, las intervenciones cerradas van al
reconocedor, la transcripción al modelo y cada frase que este cierra sale
hacia el sintetizador sin esperar al resto de la respuesta.
Dos reglas sostienen el diseño: ninguna etapa bloquea a la anterior —quien va
sobrado descarta trabajo en lugar de acumular retraso— y todo lo que viaja por
los canales lleva el turno al que pertenece, así que interrumpir es subir el
contador y levantar dos banderas de cancelación.
Seis crates: core (configuración, eventos, HTTP, telemetría, herramientas),
audio (cpal, VAD, anillo de reproducción), asr, llm, tts y app (supervisor de
procesos y orquestador). Los motores van como submódulos fijados a un commit,
con los cambios locales en vendor/patches.
Midiendo el pipeline aparecieron tres cuellos de botella de configuración que
valieron más que cualquier cambio de código, todos documentados en
docs/RENDIMIENTO.md:
- tts-server decodificaba el audio en bloques de 24 s, de modo que el modo
«streaming» llegaba de una pieza: 4948 ms -> 585 ms hasta el primer audio.
- La plantilla de chat del modelo abre <think> y no lo cierra nunca, sin
variable que lo apague: 8630 ms -> 413 ms hasta el primer token, con una
copia de la plantilla que deja el bloque cerrado de entrada.
- Cualquier indicación de estilo junto a la guía de herramientas hace que este
modelo de 2B deje de llamarlas y se invente el dato (8/8 aciertos con la
guía sola, 0/8 con la persona de asistente de voz). El turno alterna ahora
entre dos instrucciones de sistema.
La ejecución de órdenes del sistema queda implementada y apagada, tras cuatro
barreras: lista blanca sobre el ejecutable, rutas rechazadas, sin shell que
interprete metacaracteres y plazo máximo.
68 pruebas unitarias sin modelos, más seis de integración que se saltan solas
si no hay servidores y se turnan la GPU: en paralelo, los dos servidores no
caben en 4 GB y miden contención en vez de latencia.
Claude-Session: https://claude.ai/code/session_01FNxz5cSdQSscJH9H7b8uGU
Diffstat (limited to 'crates/asist-core/src/tools.rs')
| -rw-r--r-- | crates/asist-core/src/tools.rs | 563 |
1 files changed, 563 insertions, 0 deletions
diff --git a/crates/asist-core/src/tools.rs b/crates/asist-core/src/tools.rs new file mode 100644 index 0000000..0b8a646 --- /dev/null +++ b/crates/asist-core/src/tools.rs @@ -0,0 +1,563 @@ +//! 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<String>; + + /// `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 + } +} + +/// 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<String, Arc<dyn Tool>>, +} + +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<dyn Tool>) { + 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<dyn Tool>> { + 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::<Value>(&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::process::{Command, Stdio}; + + /// 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<String> { + // `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<String> { + 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<String>, + 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<String> { + 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<String> = 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::<Result<_>>()?, + // 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 mut command = Command::new(program); + command + .args(&arguments) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if !self.working_dir.is_empty() { + command.current_dir(&self.working_dir); + } + + let mut child = command.spawn().map_err(|e| fail(e.to_string()))?; + let deadline = std::time::Instant::now() + self.timeout; + loop { + match child.try_wait().map_err(|e| fail(e.to_string()))? { + Some(_) => break, + None if std::time::Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return Err(fail(format!( + "«{}» superó el plazo de {} s y se detuvo", + rendered.trim(), + self.timeout.as_secs() + ))); + } + None => std::thread::sleep(Duration::from_millis(20)), + } + } + + let output = child.wait_with_output().map_err(|e| fail(e.to_string()))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let mut body = stdout.trim().to_string(); + if body.is_empty() { + body = 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::<String>() + "… (salida recortada)"; + } + if !output.status.success() { + return Ok(format!( + "la orden terminó con código {}: {body}", + output.status.code().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, + 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(); + assert!(err.to_string().contains("plazo"), "{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"); + } +} |