# Extender el asistente ## Añadir una herramienta Las herramientas son el punto de extensión pensado para lo que venga después: ejecutar órdenes, consultar cosas, encender luces. Una herramienta se describe a sí misma en JSON Schema y sabe ejecutarse; el registro se encarga del resto. Implementa `asist_core::tools::Tool`: ```rust use asist_core::error::{Error, Result}; use asist_core::tools::Tool; use serde_json::{json, Value}; pub struct Temperatura; impl Tool for Temperatura { fn name(&self) -> &str { "temperatura_cpu" } // La lee el modelo, así que se escribe para él: concreta y en español. fn description(&self) -> &str { "Devuelve la temperatura actual de la CPU en grados. Úsala cuando \ pregunten si el equipo está caliente." } fn parameters(&self) -> Value { json!({ "type": "object", "properties": {}, "required": [] }) } fn call(&self, _args: &Value) -> Result { let raw = std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp") .map_err(|e| Error::Tool { tool: self.name().into(), message: e.to_string() })?; let grados = raw.trim().parse::().unwrap_or(0.0) / 1000.0; Ok(format!("{grados:.1} grados")) } // Ponlo a true si cambia algo fuera del proceso. fn is_side_effecting(&self) -> bool { false } } ``` Y regístrala en `ToolRegistry::from_config` (en `crates/asist-core/src/tools.rs`) o directamente antes de montar el pipeline: ```rust let mut tools = ToolRegistry::from_config(&config.tools); tools.register(std::sync::Arc::new(Temperatura)); ``` Tres cosas que conviene saber antes de escribir la primera: - **El error nunca tumba el turno.** `dispatch` convierte un fallo en texto y se lo devuelve al modelo, que lo explica o reintenta. - **La salida se lee en voz alta.** Devuelve una frase, no una tabla. El registro recorta a 2000 caracteres, pero mucho antes de eso ya aburre. - **Con este modelo, más herramientas es peor.** Qwen3.5-2B ya es frágil eligiendo entre dos; ver el apartado 3 de [RENDIMIENTO.md](RENDIMIENTO.md). ## Ejecutar órdenes del sistema Ya está implementado y apagado (`tools.shell = false`). Encenderlo es un cambio de postura de seguridad, no una comodidad: lo que llega a esa herramienta viene, en última instancia, de lo que se oye por el micrófono. Las cuatro barreras que tiene, y que conviene no quitar: 1. **Apagada por defecto**, y la configuración se niega a arrancar con la lista blanca vacía. 2. **Lista blanca sobre el ejecutable.** Se compara `argv[0]` exacto, y las rutas se rechazan: `/bin/echo` no cuela aunque `echo` esté permitido. 3. **Sin shell de por medio.** Los argumentos van al `execve` tal cual, así que `; rm -rf /` es un argumento literal y no otra orden. 4. **Plazo máximo**, tras el cual el proceso se mata. ```toml [tools] shell = true shell_allowlist = ["date", "uptime", "free", "df", "ls"] shell_timeout_secs = 10 shell_dry_run = true # estrena la lista sin ejecutar nada shell_working_dir = "/home/elvis" ``` Empieza con `shell_dry_run = true`: registra lo que el modelo habría ejecutado sin llegar a hacerlo, que es la forma barata de descubrir qué pide de verdad. ## Cambiar de motor Cada etapa habla con su motor a través de un único tipo, así que sustituir uno no toca el resto: | Etapa | Tipo | Fichero | |---|---|---| | Voz a texto | `asist_asr::Recognizer` | `crates/asist-asr/src/lib.rs` | | Modelo | `asist_llm::LlmClient` | `crates/asist-llm/src/client.rs` | | Texto a voz | `asist_tts::TtsClient` | `crates/asist-tts/src/lib.rs` | El cliente HTTP (`asist_core::http`) es propio y mínimo a propósito: todo es HTTP plano contra localhost, y lo que ninguna librería genérica da cómodo es **abortar una respuesta a media descarga**, que es justo lo que sostiene la interrupción. ## Añadir una etapa al pipeline El orquestador (`crates/asist-app/src/pipeline.rs`) es una cadena de hilos unidos por canales. Para meter una etapa nueva —clasificar la intención, filtrar palabrotas, registrar la conversación— añade un hilo con `spawn_named` y un canal, y respeta las dos reglas que sostienen el conjunto: 1. **Nada bloquea al que va delante.** Si vas más lento que tu emisor, descarta trabajo; no acumules retraso. 2. **Comprueba el turno antes de gastar esfuerzo.** `session.is_current(turn)` dice si lo que estás a punto de hacer sigue interesando a alguien, o si el usuario ya ha dicho otra cosa. ## Cambiar la voz ```bash # Graba unos segundos de la voz que quieras y transcríbelos a mano. scripts/clonar-voz.sh grabacion.wav transcripcion.txt mivoz ``` Deja `assets/voices/mivoz.{spk,rvq,txt}`. Apunta ahí `[tts.reference]` en la configuración. Los latentes se extraen una vez; en cada arranque se manda lo ya extraído y no la grabación. También sirven las voces del propio modelo: quita `[tts.reference]` y pon `tts.voice` con un nombre de `GET /v1/audio/voices`. ## Ideas que el diseño ya admite - **Palabra de activación.** El segmentador ya entrega intervenciones sueltas: bastaría con filtrar por la transcripción antes de abrir turno. - **Memoria entre sesiones.** `Conversation` es serializable en la práctica; guardarla y recargarla al arrancar es un cambio pequeño. - **Multimodal.** El servidor ya carga `mmproj`, así que el modelo puede ver imágenes; faltaría meterlas en el mensaje de usuario.