# 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. Si no depende de nada, en `ToolRegistry::from_config` (`crates/asist-core/src/tools.rs`); si necesita la red, el modelo o un dispositivo, en `crates/asist-app/src/registry.rs`, que es donde se montan `buscar_en_internet` y `mirar_por_la_camara`: ```rust let mut tools = ToolRegistry::from_config(&config.tools); tools.register(std::sync::Arc::new(Temperatura)); ``` Si tarda más de un segundo, dale una frase de acuse. Se pronuncia antes de ejecutar y es lo que evita que la espera se lea como un cuelgue: ```rust fn acknowledgement(&self) -> Option<&str> { Some("Déjame que lo mire.") } ``` 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.** Con cuatro declaradas elige bien entre ellas (18 de 20 medido, sin confundir cámara con pantalla), pero le cuesta abstenerse: busca en internet cosas que ya sabe. Apartados 3 y 5 de [RENDIMIENTO.md](RENDIMIENTO.md). - **Si lanzas un proceso, usa `asist_core::proc::run`.** Controla el plazo, no pasa por una shell y vacía las tuberías mientras espera. Esto último no es un detalle: un hijo que escriba más de 64 KB se cuelga si nadie las lee, y así es como la captura de pantalla parecía tardar 20 segundos. ## 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/user" ``` 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. ## Buscar en internet Ya está montado, con tres buscadores intercambiables. ```toml [search] enabled = true backend = "tavily" # "tavily", "ddgs" o "searxng" api_key_env = "TAVILY_API_KEY" command = ["../scripts/search-ddgs.sh", "{query}", "{max}"] base_url = "" # para searxng: "http://127.0.0.1:8888" max_results = 5 ``` Cuál elegir, con las mismas dos preguntas medidas contra cada uno: | | «capital de Australia» | «qué tiempo hace hoy en Buenos Aires» | Clave | |---|---|---|---| | **tavily** | 0,54 s · *«El capital de Australia es Canberra…»* | 2,88 s · *«Hoy en Buenos Aires, cielo claro, máxima de 15°C…»* | sí | | **ddgs** | 2,24 s · fragmentos de tres páginas | 3,59 s · fragmentos de tres páginas | no | La diferencia que importa no es el tiempo sino la forma: **Tavily devuelve una respuesta ya redactada** y el asistente sólo tiene que leerla, mientras que ddgs devuelve fragmentos que el modelo tiene que resumir. Con 2B eso sale bien la mayoría de las veces —medido en el pipeline completo, «Hoy en Buenos Aires hace soleado esta mañana y por la tarde tendremos nubes con temperaturas alrededor de 9°C»— pero es una oportunidad más de equivocarse. Con clave, tavily. Sin clave, ddgs. ### Sobre DuckDuckGo `scripts/search-ddgs.sh` se apoya en la librería [`ddgs`](https://github.com/deedy5/ddgs), que es la misma que hay debajo de `duckduckgo-mcp`. Consulta DuckDuckGo, Brave, Mojeek, Startpage y alguno más, sin clave. Conviene instalarla de forma permanente para que cada búsqueda no pague la resolución del paquete: ```bash uv tool install ddgs # el guion también funciona con uvx, pero más lento ``` Dos caminos que parecen equivalentes y **no funcionan**, comprobados aquí: - `curl` contra `html.duckduckgo.com/html/` devuelve **HTTP 202** con una página anti-bot (`anomaly.js`) y ni un solo resultado. - La API sin clave `api.duckduckgo.com` (Instant Answer) devuelve vacío para casi todo lo que no sea una entidad de enciclopedia: probada con «capital de Australia» y con «qué tiempo hace en Buenos Aires», ambas sin nada. La librería sí funciona porque rota buscadores y cabeceras. ### Cualquier otro buscador El backend `ddgs` es en realidad un **backend de comando**: ejecuta un programa y lee JSON de su salida estándar. `{query}` y `{max}` se sustituyen antes de ejecutar, y el extractor acepta tanto la lista suelta que devuelve ddgs como la forma `{answer, results}`, con los campos nombrados a la manera de cada uno (`href` o `url`, `body` o `content`). Así que meter otro buscador —o un puente a un servidor MCP, que es un proceso que habla JSON-RPC por stdio— no es tocar Rust, sino escribir un guion: ```toml command = ["/ruta/a/mi-buscador.sh", "{query}", "{max}"] ``` Los argumentos van al `execve` tal cual, sin shell que los interprete: la consulta sale de lo que se ha oído por el micrófono y no puede acabar ejecutándose. ## Ver: la cámara y la pantalla Las dos hacen lo mismo en tres pasos —conseguir un JPEG, preguntarle al modelo por él, devolver texto— y comparten `VisionTool`. Lo único que las separa es `FrameSource`, que es de dónde salen los píxeles: ```rust pub trait FrameSource: Send + Sync { fn label(&self) -> &str; fn capture(&self) -> Result>; // JPEG fn available(&self) -> Result<()>; // se consulta antes de registrar } ``` Añadir una fuente nueva —una cámara IP, una ventana concreta, un PDF abierto— es implementar eso y llamar a `VisionTool::screen` o escribir una variante. ```toml [camera] enabled = true device = "/dev/video0" width = 640 # escena: 2,9 s aquí, 7,8 s a 720p, misma descripción útil height = 480 warmup_frames = 5 # deja que se asiente la exposición automática save_dir = "" # vacío = no se guarda ningún fotograma [screen] enabled = true command = ["../scripts/capture-screen.sh", "{width}", "{output}"] width = 1280 # texto: NO lo bajes, ver abajo output = "" # monitor concreto; vacío = todo save_dir = "" # vacío = no se guarda ninguna captura ``` ### Por qué la pantalla usa 1280 y la cámara 640 Porque una escena se entiende y un texto se lee, y son cosas distintas. Medido con tipografía de interfaz de 13 px: | Ancho | Tiempo | Aciertos sobre 3 preguntas | |---|---|---| | 640 px | 2,4 s | 1 — **se inventa el contenido** | | 960 px | 4,5 s | 2 | | 1280 px | 7,6 s | 3 | A 640 px el modelo no dijo «no lo leo»: dijo que el error era «no se pudo abrir el archivo *involution*» y que la reunión era «a las 10:00». Ninguna de las dos cosas estaba en la imagen. Si bajas `screen.width`, esto es lo que compras. El prompt de la pantalla lleva además «lee sólo lo que de verdad pone y no completes lo que no se distinga», y funciona: en una prueba real contestó que «el texto de la barra de direcciones es ilegible por estar borroso». ### Otro entorno gráfico `scripts/capture-screen.sh` detecta grim (Wayland wlroots), spectacle (KDE), maim, ImageMagick y scrot (X11), reduce con ffmpeg y escribe el JPEG por la salida estándar. Está fuera del binario para que añadir un compositor sea editar el guion, no recompilar. Cualquier otro programa que respete ese contrato vale: ```toml command = ["/ruta/a/mi-captura.sh", "{width}", "{output}"] ``` ### Tres decisiones que conviene no deshacer - **La pregunta viaja hasta la fuente.** El modelo rellena `pregunta` con lo que quiere averiguar, y esa pregunta acompaña a la imagen. Pedir una descripción genérica y luego interrogarla pierde el detalle que se buscaba. - **La imagen no entra en el historial.** Cientos de tokens de contexto por turno para algo que ya está descrito en una frase. Lo que vuelve a la conversación es el texto. - **No se guarda nada en disco.** `save_dir` existe sólo para depurar. En la pantalla pesa más que en la cámara: ahí caben contraseñas, mensajes privados y correo abierto. ## 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` | | Visión | `asist_llm::LlmClient::look` | `crates/asist-llm/src/client.rs` | | Buscar y ver | `asist_tools` | `crates/asist-tools/src/` | | Lanzar procesos | `asist_core::proc::run` | `crates/asist-core/src/proc.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. Hay un segundo cliente, `ureq`, sólo en `asist-tools`: un buscador está detrás de HTTPS, y ahí hace falta TLS y no hace falta cortar a media descarga. Son dos clientes porque resuelven dos problemas distintos, no por descuido. ## 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/clone-voice.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.