aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-core/src/tools.rs
diff options
context:
space:
mode:
authorelvis <elvis@claros.ar>2026-09-06 19:21:16 -0300
committerelvis <elvis@claros.ar>2026-09-06 19:23:37 -0300
commitf8f98e83481e7376a235fb305a095552433f79ab (patch)
tree6d0ecddb26bef8430a086431de40f8ce9b1039e6 /crates/asist-core/src/tools.rs
downloadasist-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.rs563
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");
+ }
+}