aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md20
-rw-r--r--config/asistente.toml24
-rw-r--r--crates/asist-app/src/main.rs27
-rw-r--r--crates/asist-app/src/registry.rs67
-rw-r--r--crates/asist-app/tests/integracion.rs129
-rw-r--r--crates/asist-core/src/config.rs49
-rw-r--r--crates/asist-core/src/lib.rs1
-rw-r--r--crates/asist-core/src/proc.rs228
-rw-r--r--crates/asist-core/src/tools.rs48
-rw-r--r--crates/asist-tools/src/camera.rs313
-rw-r--r--crates/asist-tools/src/lib.rs16
-rw-r--r--crates/asist-tools/src/screen.rs189
-rw-r--r--crates/asist-tools/src/search.rs34
-rw-r--r--crates/asist-tools/src/vision.rs206
-rw-r--r--docs/EXTENDER.md91
-rw-r--r--docs/RENDIMIENTO.md73
-rwxr-xr-xscripts/capturar-pantalla.sh52
17 files changed, 1213 insertions, 354 deletions
diff --git a/README.md b/README.md
index 90d108e..bb7c407 100644
--- a/README.md
+++ b/README.md
@@ -63,7 +63,7 @@ cargo run --release -- run --no-manage
| `asist-asr` | Canary en ONNX: ventana deslizante y transcripción final |
| `asist-llm` | llama-server: streaming SSE, historial, llamadas a herramientas |
| `asist-tts` | qwentts: síntesis en streaming y voces clonadas |
-| `asist-tools` | Buscar en internet y mirar por la cámara |
+| `asist-tools` | Buscar en internet, mirar por la cámara y ver la pantalla |
| `asist-app` | Supervisor de procesos, orquestador y binario `asistente` |
Los tres motores viven en `vendor/` como submódulos fijados a un commit
@@ -123,6 +123,7 @@ de configuración valieron más que cualquier cambio de código:
- El prompt de estilo anulaba las llamadas a herramientas: **0/8 → 8/8**.
- La cámara capturaba a 720p: **7,8 s → 2,9 s** por imagen a 640x480.
- Las herramientas se ejecutaban en silencio: **~8,4 s → 4,1 s** hasta oír algo.
+- La pantalla a 640 px inventaba el texto: **1 de 3 aciertos → 3 de 3** a 1280 px.
Los tres, con el porqué y cómo se midieron, en
[docs/RENDIMIENTO.md](docs/RENDIMIENTO.md).
@@ -135,7 +136,8 @@ El asistente puede llamar a funciones. Vienen cuatro:
|---|---|---|
| `hora_actual` | Fecha y hora del sistema | 0 ms |
| `buscar_en_internet` | Busca y resume | 0,5–3,6 s |
-| `mirar_por_la_camara` | Hace una foto y responde sobre lo que ve | 4,2–5,2 s |
+| `mirar_por_la_camara` | Hace una foto y responde sobre lo que ve | 4,1–5,2 s |
+| `mirar_la_pantalla` | Captura la pantalla y responde sobre lo que hay | 7,6–12,4 s |
| `ejecutar_comando` | Órdenes del sistema, **apagada** por defecto | — |
Mientras una herramienta trabaja, el asistente dice «Déjame que lo busque» o
@@ -147,9 +149,14 @@ ya redactada), `ddgs` (sin clave, la misma librería que hay bajo
`duckduckgo-mcp`) y `searxng`. El de comando ejecuta un guion que escriba JSON,
así que meter otro es escribir un guion, no tocar Rust.
-**Mirar** aprovecha que el servidor ya carga el proyector multimodal: el mismo
-modelo que conversa describe la imagen. La foto no se guarda en disco ni entra
-en el historial.
+**Ver** —cámara y pantalla— aprovecha que el servidor ya carga el proyector
+multimodal: el mismo modelo que conversa describe la imagen. Ni la foto ni la
+captura se guardan en disco, y no entran en el historial.
+
+La pantalla usa 1280 px y la cámara 640, y la diferencia importa: una escena se
+entiende, pero un texto hay que leerlo. Medido, a 640 px el modelo no dice que
+no lee la pantalla, **se inventa lo que pone** —contestó que la reunión era «a
+las 10:00» cuando ponía 15:30—. Por eso el triple de coste.
`ejecutar_comando` está apagada a conciencia: darle una shell a un modelo que
obedece a lo que oye por el micrófono es un cambio de postura de seguridad.
@@ -184,6 +191,7 @@ frase y comprueba que el reconocedor la recupera.
- CUDA para los motores en C++ (funcionan en CPU, pero muy justos)
- PipeWire o ALSA
- ~6 GB de disco para los modelos
-- `ffmpeg` para la cámara
+- `ffmpeg` para la cámara y para reducir las capturas
+- Para la pantalla: `grim` (Wayland) o `maim`/ImageMagick (X11)
- Para buscar: una clave de Tavily en `$TAVILY_API_KEY`, o `uv tool install ddgs`
diff --git a/config/asistente.toml b/config/asistente.toml
index a219142..a4f7e5e 100644
--- a/config/asistente.toml
+++ b/config/asistente.toml
@@ -172,6 +172,30 @@ timeout_secs = 15
# Ponle una carpeta sólo para depurar qué está viendo el modelo.
save_dir = ""
+# Mirar la pantalla. Es la misma visión que la cámara, pero con un ajuste
+# distinto porque el problema es distinto: una pantalla es TEXTO.
+#
+# Medido con tipografía de interfaz de 13 px, preguntando por datos concretos:
+# 1280 px 7,6 s acierta 3 de 3
+# 960 px 4,5 s acierta 2 de 3
+# 640 px 2,4 s acierta 1 de 3
+#
+# Y cuando falla no dice que no lo lee: se lo inventa. A 640 px contestó 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. Por eso 1280 aunque
+# cueste el triple que la cámara.
+[screen]
+enabled = true
+# Detecta el entorno gráfico (grim en Wayland, maim/imagemagick en X11), captura
+# y reduce a {ancho}. Editar el guion es más fácil que recompilar.
+command = ["../scripts/capturar-pantalla.sh", "{ancho}", "{salida}"]
+width = 1280
+output = "" # monitor concreto; vacío = todo
+timeout_secs = 20
+# Vacío = no se guarda ninguna captura. Aquí pesa más que en la cámara: en una
+# captura de pantalla caben contraseñas, mensajes privados y correo abierto.
+save_dir = ""
+
[supervisor]
manage = true # lanzar los servidores; --no-manage los supone arriba
startup_timeout_secs = 180
diff --git a/crates/asist-app/src/main.rs b/crates/asist-app/src/main.rs
index 7b00b1f..cc08e62 100644
--- a/crates/asist-app/src/main.rs
+++ b/crates/asist-app/src/main.rs
@@ -109,6 +109,17 @@ fn run(config: Config, args: &Args) -> Result<()> {
// Las dos capacidades que tocan algo fuera del proceso se anuncian: una
// enciende la cámara y la otra ejecuta órdenes. Quien lo arranca debería
// saberlo sin tener que leerse la configuración.
+ if tools.get("mirar_la_pantalla").is_some() {
+ eprintln!(
+ "Captura de pantalla ACTIVA — a {} px{}",
+ config.screen.width,
+ if config.screen.save_dir.is_empty() {
+ String::new()
+ } else {
+ format!(", guardando en {}", config.screen.save_dir)
+ }
+ );
+ }
if tools.get("mirar_por_la_camara").is_some() {
eprintln!(
"Cámara ACTIVA — {} a {}x{}{}",
@@ -320,6 +331,22 @@ fn check(config: &Config) -> Result<()> {
}
}
+ if !config.screen.enabled {
+ println!(" - pantalla desactivada en la configuración");
+ } else {
+ match config.screen.command.first() {
+ Some(program) if !program.contains('/') || PathBuf::from(program).exists() => ok(
+ "pantalla",
+ format!("{} px · {}", config.screen.width, program),
+ ),
+ Some(program) => println!(" - pantalla no existe {program}"),
+ None => println!(" - pantalla screen.command está vacío"),
+ }
+ if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() {
+ println!(" - sesión gráfica no hay; la pantalla no se podrá capturar");
+ }
+ }
+
if !config.camera.enabled {
println!(" - cámara desactivada en la configuración");
} else if config.camera.device.exists() {
diff --git a/crates/asist-app/src/registry.rs b/crates/asist-app/src/registry.rs
index 13bbb75..a3a10ed 100644
--- a/crates/asist-app/src/registry.rs
+++ b/crates/asist-app/src/registry.rs
@@ -8,11 +8,12 @@
use std::sync::Arc;
use std::time::Duration;
-use asist_core::config::{CameraConfig, Config, SearchConfig};
+use asist_core::config::{self as cfg, Config, SearchConfig};
use asist_core::tools::ToolRegistry;
use asist_llm::LlmClient;
-use asist_tools::camera::CaptureConfig;
-use asist_tools::{Camera, SearchBackend, WebSearch};
+use asist_tools::{
+ Camera, CameraConfig, Screen, ScreenConfig, SearchBackend, VisionTool, WebSearch,
+};
/// Lo que no se pudo activar y por qué, para decírselo al usuario al arrancar
/// en vez de dejarlo en silencio.
@@ -38,13 +39,16 @@ pub fn build(config: &Config, llm: &Arc<LlmClient>) -> (ToolRegistry, Vec<Skippe
}),
}
- match camera_tool(&config.camera, llm) {
- Ok(Some(tool)) => registry.register(Arc::new(tool)),
- Ok(None) => {}
- Err(reason) => skipped.push(Skipped {
- tool: "mirar_por_la_camara",
- reason,
- }),
+ // Las dos que ven comparten forma, así que se registran igual.
+ for (result, tool) in [
+ (camera_tool(&config.camera, llm), "mirar_por_la_camara"),
+ (screen_tool(&config.screen, llm), "mirar_la_pantalla"),
+ ] {
+ match result {
+ Ok(Some(vision)) => registry.register(Arc::new(vision)),
+ Ok(None) => {}
+ Err(reason) => skipped.push(Skipped { tool, reason }),
+ }
}
(registry, skipped)
@@ -98,25 +102,46 @@ fn search_tool(config: &SearchConfig) -> Result<Option<WebSearch>, String> {
)))
}
-fn camera_tool(config: &CameraConfig, llm: &Arc<LlmClient>) -> Result<Option<Camera>, String> {
+fn camera_tool(
+ config: &cfg::CameraConfig,
+ llm: &Arc<LlmClient>,
+) -> Result<Option<VisionTool>, String> {
if !config.enabled {
return Ok(None);
}
- let capture = CaptureConfig {
+ let source = Camera::new(CameraConfig {
device: config.device.clone(),
width: config.width,
height: config.height,
warmup_frames: config.warmup_frames,
timeout: Duration::from_secs(config.timeout_secs),
save_dir: (!config.save_dir.is_empty()).then(|| config.save_dir.clone().into()),
- };
- // Registrar una herramienta que va a fallar siempre es peor que no tenerla:
- // el modelo la llamaría, se comería el error y gastaría un turno entero.
- if !Camera::available(&capture) {
- return Err(format!(
- "no existe {}; comprueba con «v4l2-ctl --list-devices»",
- capture.device.display()
- ));
+ });
+ finish(VisionTool::camera(Box::new(source), Arc::clone(llm)))
+}
+
+fn screen_tool(
+ config: &cfg::ScreenConfig,
+ llm: &Arc<LlmClient>,
+) -> Result<Option<VisionTool>, String> {
+ if !config.enabled {
+ return Ok(None);
+ }
+ let source = Screen::new(ScreenConfig {
+ command: config.command.clone(),
+ width: config.width,
+ output: config.output.clone(),
+ timeout: Duration::from_secs(config.timeout_secs),
+ save_dir: (!config.save_dir.is_empty()).then(|| config.save_dir.clone().into()),
+ });
+ finish(VisionTool::screen(Box::new(source), Arc::clone(llm)))
+}
+
+/// Registrar una herramienta que va a fallar siempre es peor que no tenerla:
+/// el modelo la llamaría, se comería el error y gastaría un turno entero.
+fn finish(tool: VisionTool) -> Result<Option<VisionTool>, String> {
+ match tool.available() {
+ Ok(()) => Ok(Some(tool)),
+ Err(err) => Err(err.to_string()),
}
- Ok(Some(Camera::new(capture, Arc::clone(llm))))
}
diff --git a/crates/asist-app/tests/integracion.rs b/crates/asist-app/tests/integracion.rs
index a7f7e15..d0fca38 100644
--- a/crates/asist-app/tests/integracion.rs
+++ b/crates/asist-app/tests/integracion.rs
@@ -17,6 +17,7 @@ use asist_core::http::Cancel;
use asist_core::tools::{Tool, ToolRegistry};
use asist_llm::chat::Conversation;
use asist_llm::{Delta, LlmClient, Message};
+use asist_tools::FrameSource;
use asist_tts::TtsClient;
/// Las pruebas se turnan la GPU.
@@ -346,24 +347,62 @@ fn una_consulta_inventada_no_revienta_el_turno() {
);
}
-#[test]
-fn la_camara_captura_un_jpeg() {
- let config = config();
- let capture = asist_tools::camera::CaptureConfig {
+fn camera_source(config: &Config) -> Option<asist_tools::Camera> {
+ if !config.camera.enabled || !config.camera.device.exists() {
+ eprintln!("sin cámara; prueba omitida");
+ return None;
+ }
+ Some(asist_tools::Camera::new(asist_tools::CameraConfig {
device: config.camera.device.clone(),
width: config.camera.width,
height: config.camera.height,
warmup_frames: config.camera.warmup_frames,
timeout: Duration::from_secs(config.camera.timeout_secs),
save_dir: None,
- };
- if !config.camera.enabled || !capture.device.exists() {
- eprintln!("sin cámara; prueba omitida");
- return;
+ }))
+}
+
+fn screen_source(config: &Config) -> Option<asist_tools::Screen> {
+ let screen = asist_tools::Screen::new(asist_tools::ScreenConfig {
+ command: config.screen.command.clone(),
+ width: config.screen.width,
+ output: config.screen.output.clone(),
+ timeout: Duration::from_secs(config.screen.timeout_secs),
+ save_dir: None,
+ });
+ if !config.screen.enabled || screen.available().is_err() {
+ eprintln!("sin pantalla capturable; prueba omitida");
+ return None;
+ }
+ Some(screen)
+}
+
+/// Ancho de un JPEG, leído de su cabecera SOF.
+fn jpeg_width(bytes: &[u8]) -> Option<u32> {
+ let mut i = 2;
+ while i + 9 < bytes.len() {
+ if bytes[i] != 0xFF {
+ return None;
+ }
+ let marker = bytes[i + 1];
+ let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
+ if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
+ return Some(u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32);
+ }
+ i += 2 + len;
}
+ None
+}
+
+#[test]
+fn la_camara_captura_un_jpeg() {
+ let config = config();
+ let Some(source) = camera_source(&config) else {
+ return;
+ };
let started = Instant::now();
- let frame = asist_tools::camera::capture(&capture).expect("la captura falló");
+ let frame = source.capture().expect("la captura falló");
eprintln!(
"fotograma de {} KB en {:?}",
frame.len() / 1024,
@@ -380,26 +419,43 @@ fn la_camara_captura_un_jpeg() {
}
#[test]
+fn la_pantalla_se_captura_al_ancho_configurado() {
+ let config = config();
+ let Some(source) = screen_source(&config) else {
+ return;
+ };
+
+ let started = Instant::now();
+ let frame = source.capture().expect("la captura falló");
+ eprintln!(
+ "captura de {} KB en {:?}",
+ frame.len() / 1024,
+ started.elapsed()
+ );
+ assert_eq!(&frame[..2], &[0xFF, 0xD8], "no parece un JPEG");
+
+ // El ancho es lo que separa leer de inventar, así que se comprueba que el
+ // guion de verdad redujo la imagen y no la mandó a tamaño completo.
+ let width = jpeg_width(&frame).expect("no se pudo leer el ancho del JPEG");
+ assert_eq!(
+ width, config.screen.width,
+ "la captura salió a {width} px y se pedían {}",
+ config.screen.width
+ );
+}
+
+#[test]
#[ignore = "enciende la cámara y hace una pasada de visión; lento"]
fn el_modelo_describe_lo_que_ve_la_camara() {
let _gpu = en_exclusiva();
let Some((config, llm, _)) = clientes() else {
return;
};
- let capture = asist_tools::camera::CaptureConfig {
- device: config.camera.device.clone(),
- width: config.camera.width,
- height: config.camera.height,
- warmup_frames: config.camera.warmup_frames,
- timeout: Duration::from_secs(config.camera.timeout_secs),
- save_dir: None,
- };
- if !config.camera.enabled || !capture.device.exists() {
- eprintln!("sin cámara; prueba omitida");
+ let Some(source) = camera_source(&config) else {
return;
- }
+ };
- let tool = asist_tools::Camera::new(capture, std::sync::Arc::new(llm));
+ let tool = asist_tools::VisionTool::camera(Box::new(source), std::sync::Arc::new(llm));
let started = Instant::now();
let out = tool
.call(&serde_json::json!({ "pregunta": "¿Qué se ve en la imagen?" }))
@@ -418,6 +474,37 @@ fn el_modelo_describe_lo_que_ve_la_camara() {
}
#[test]
+#[ignore = "captura la pantalla y hace una pasada de visión; lento"]
+fn el_modelo_describe_la_pantalla() {
+ let _gpu = en_exclusiva();
+ let Some((config, llm, _)) = clientes() else {
+ return;
+ };
+ let Some(source) = screen_source(&config) else {
+ return;
+ };
+
+ let tool = asist_tools::VisionTool::screen(Box::new(source), std::sync::Arc::new(llm));
+ let started = Instant::now();
+ let out = tool
+ .call(&serde_json::json!({ "pregunta": "¿Qué hay en la pantalla?" }))
+ .expect("la descripción falló");
+
+ eprintln!("pantalla en {:?}: {out}", started.elapsed());
+ assert!(
+ out.split_whitespace().count() >= 3,
+ "descripción vacía o mínima: {out}"
+ );
+ // A 1280 px se midieron 7,6 s. Si esto se dispara, o la GPU está ocupada o
+ // alguien subió screen.width sin mirar el coste.
+ assert!(
+ started.elapsed() < Duration::from_secs(30),
+ "tardó {:?}; revisa screen.width",
+ started.elapsed()
+ );
+}
+
+#[test]
#[ignore = "sale a internet con los dos buscadores; lento"]
fn los_dos_buscadores_responden_a_lo_mismo() {
// Compara lo que devuelve cada uno para las mismas preguntas. No afirma
diff --git a/crates/asist-core/src/config.rs b/crates/asist-core/src/config.rs
index 792ad3f..c2a9d55 100644
--- a/crates/asist-core/src/config.rs
+++ b/crates/asist-core/src/config.rs
@@ -19,6 +19,7 @@ pub struct Config {
pub tools: ToolsConfig,
pub search: SearchConfig,
pub camera: CameraConfig,
+ pub screen: ScreenConfig,
pub supervisor: SupervisorConfig,
}
@@ -430,6 +431,48 @@ impl Default for CameraConfig {
}
}
+/// Mirar la pantalla.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct ScreenConfig {
+ pub enabled: bool,
+ /// Programa de captura. `{ancho}` y `{salida}` se sustituyen antes de
+ /// ejecutar, y tiene que escribir un JPEG por la salida estándar. Está
+ /// fuera del binario para que añadir un compositor sea editar un guion.
+ pub command: Vec<String>,
+ /// Ancho al que se reduce la captura antes de mandarla al modelo.
+ ///
+ /// **No lo bajes a la ligera.** Medido con tipografía de interfaz de 13 px
+ /// y preguntando por datos concretos: a 1280 px acierta 3 de 3 en 7,6 s; a
+ /// 960, 2 de 3; a 640, 1 de 3. Y cuando falla no dice que no lo lee: se
+ /// inventa el contenido con aplomo.
+ pub width: u32,
+ /// Monitor concreto. Vacío = todo lo que haya.
+ pub output: String,
+ pub timeout_secs: u64,
+ /// Carpeta donde guardar las capturas. Vacío = no se guarda ninguna, que
+ /// es lo que corresponde: en una captura caben contraseñas y mensajes
+ /// privados.
+ pub save_dir: String,
+}
+
+impl Default for ScreenConfig {
+ fn default() -> Self {
+ Self {
+ enabled: true,
+ command: vec![
+ "scripts/capturar-pantalla.sh".into(),
+ "{ancho}".into(),
+ "{salida}".into(),
+ ],
+ width: 1280,
+ output: String::new(),
+ timeout_secs: 20,
+ save_dir: String::new(),
+ }
+ }
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SupervisorConfig {
@@ -569,6 +612,12 @@ impl Config {
fix(&mut self.supervisor.tts.binary);
fix(&mut self.supervisor.tts.model);
fix(&mut self.supervisor.tts.codec);
+ if let Some(program) = self.screen.command.first_mut() {
+ let path = PathBuf::from(&*program);
+ if path.is_relative() && program.contains('/') {
+ *program = base.join(path).to_string_lossy().into_owned();
+ }
+ }
if let Some(program) = self.search.command.first_mut() {
let path = PathBuf::from(&*program);
if path.is_relative() && program.contains('/') {
diff --git a/crates/asist-core/src/lib.rs b/crates/asist-core/src/lib.rs
index db3ebfb..87fbc75 100644
--- a/crates/asist-core/src/lib.rs
+++ b/crates/asist-core/src/lib.rs
@@ -9,6 +9,7 @@ pub mod config;
pub mod error;
pub mod event;
pub mod http;
+pub mod proc;
pub mod telemetry;
pub mod text;
pub mod tools;
diff --git a/crates/asist-core/src/proc.rs b/crates/asist-core/src/proc.rs
new file mode 100644
index 0000000..8e38108
--- /dev/null
+++ b/crates/asist-core/src/proc.rs
@@ -0,0 +1,228 @@
+//! Ejecutar un programa con un plazo máximo.
+//!
+//! Tres sitios lo necesitaban con los mismos cuidados —no dejar nunca un
+//! proceso colgado, no pasar por una shell, distinguir «falló» de «tardó
+//! demasiado»— y cada uno lo tenía escrito a su manera. Aquí está una sola vez.
+//!
+//! Nada de esto pasa por `sh`: los argumentos van al `execve` tal cual. Importa
+//! más de lo que parece, porque lo que acaba en ellos viene, en última
+//! instancia, de lo que se ha oído por el micrófono.
+
+use std::ffi::OsStr;
+use std::io::Read;
+use std::path::Path;
+use std::process::{Command, Stdio};
+use std::time::{Duration, Instant};
+
+/// Cómo terminó.
+#[derive(Debug)]
+pub struct Output {
+ pub stdout: Vec<u8>,
+ pub stderr: String,
+ pub status: Option<i32>,
+ pub took: Duration,
+}
+
+impl Output {
+ pub fn success(&self) -> bool {
+ self.status == Some(0)
+ }
+
+ /// Última línea del error, que es donde los programas suelen poner el
+ /// motivo de verdad.
+ pub fn last_error_line(&self) -> &str {
+ self.stderr
+ .trim()
+ .lines()
+ .next_back()
+ .unwrap_or("sin detalles")
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum ProcError {
+ #[error("no se pudo ejecutar {program}: {source}")]
+ Spawn {
+ program: String,
+ #[source]
+ source: std::io::Error,
+ },
+ #[error("{program} tardó más de {} s", timeout.as_secs())]
+ Timeout { program: String, timeout: Duration },
+ #[error("fallo esperando a {program}: {source}")]
+ Wait {
+ program: String,
+ #[source]
+ source: std::io::Error,
+ },
+}
+
+/// Lanza `program`, espera hasta `timeout` y devuelve lo que haya escrito.
+///
+/// Si vence el plazo, mata el proceso antes de rendirse: un ffmpeg contra una
+/// cámara ocupada, o un buscador que no contesta, se quedarían ahí para
+/// siempre.
+pub fn run(
+ program: impl AsRef<Path>,
+ args: impl IntoIterator<Item = impl AsRef<OsStr>>,
+ timeout: Duration,
+ working_dir: Option<&Path>,
+) -> Result<Output, ProcError> {
+ let program = program.as_ref();
+ let name = program.display().to_string();
+ let started = Instant::now();
+
+ let mut command = Command::new(program);
+ command
+ .args(args)
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ if let Some(dir) = working_dir {
+ command.current_dir(dir);
+ }
+
+ let mut child = command.spawn().map_err(|source| ProcError::Spawn {
+ program: name.clone(),
+ source,
+ })?;
+
+ // Las tuberías se vacían en hilos aparte, y no después de esperar.
+ //
+ // Esto no es una optimización: un hijo que escribe más de lo que cabe en el
+ // búfer de la tubería (64 KB en Linux) se queda bloqueado escribiendo, no
+ // termina nunca, y el bucle de espera acaba matándolo por plazo vencido
+ // aunque estuviera haciendo su trabajo. Costó descubrirlo porque los tres
+ // primeros usos —una fecha, un JSON de búsqueda, un fotograma de 9 KB—
+ // cabían de sobra; la primera captura de pantalla, de 180 KB, no.
+ let mut stdout = child.stdout.take();
+ let mut stderr = child.stderr.take();
+ let stdout_reader = std::thread::spawn(move || {
+ let mut buf = Vec::new();
+ if let Some(pipe) = stdout.as_mut() {
+ let _ = pipe.read_to_end(&mut buf);
+ }
+ buf
+ });
+ let stderr_reader = std::thread::spawn(move || {
+ let mut buf = Vec::new();
+ if let Some(pipe) = stderr.as_mut() {
+ let _ = pipe.read_to_end(&mut buf);
+ }
+ buf
+ });
+
+ let deadline = started + timeout;
+ let status = loop {
+ match child.try_wait() {
+ Ok(Some(status)) => break status,
+ Ok(None) if Instant::now() >= deadline => {
+ // Matar cierra las tuberías, así que los lectores terminan y
+ // se pueden recoger sin quedarse colgados.
+ let _ = child.kill();
+ let _ = child.wait();
+ let _ = stdout_reader.join();
+ let _ = stderr_reader.join();
+ return Err(ProcError::Timeout {
+ program: name,
+ timeout,
+ });
+ }
+ Ok(None) => std::thread::sleep(Duration::from_millis(20)),
+ Err(source) => {
+ return Err(ProcError::Wait {
+ program: name,
+ source,
+ })
+ }
+ }
+ };
+
+ let stdout = stdout_reader.join().unwrap_or_default();
+ let stderr = stderr_reader.join().unwrap_or_default();
+
+ Ok(Output {
+ stdout,
+ stderr: String::from_utf8_lossy(&stderr).into_owned(),
+ status: status.code(),
+ took: started.elapsed(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn se_recoge_la_salida_estandar() {
+ let out = run("echo", ["hola"], Duration::from_secs(5), None).unwrap();
+ assert!(out.success());
+ assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hola");
+ }
+
+ #[test]
+ fn un_proceso_colgado_se_mata_al_vencer_el_plazo() {
+ let started = Instant::now();
+ let err = run("sleep", ["30"], Duration::from_millis(300), None).unwrap_err();
+ assert!(matches!(err, ProcError::Timeout { .. }), "{err}");
+ assert!(
+ started.elapsed() < Duration::from_secs(3),
+ "debió cortar enseguida, tardó {:?}",
+ started.elapsed()
+ );
+ }
+
+ #[test]
+ fn una_salida_grande_no_bloquea_al_hijo() {
+ // La regresión que dejó colgada la captura de pantalla: sin vaciar la
+ // tubería mientras se espera, un hijo que escribe más de 64 KB se
+ // bloquea y acaba muriendo por plazo vencido.
+ let out = run(
+ "dd",
+ ["if=/dev/zero", "bs=1024", "count=512", "status=none"],
+ Duration::from_secs(10),
+ None,
+ )
+ .expect("no debió vencer el plazo");
+ assert!(out.success());
+ assert_eq!(out.stdout.len(), 512 * 1024);
+ }
+
+ #[test]
+ fn el_error_grande_tampoco_bloquea() {
+ // Mismo problema por la otra tubería: mucho ruido en stderr y poca
+ // salida es justo lo que hace ffmpeg cuando algo va mal.
+ let out = run(
+ "sh",
+ ["-c", "yes error | head -c 200000 >&2; echo ok"],
+ Duration::from_secs(10),
+ None,
+ )
+ .expect("no debió vencer el plazo");
+ assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ok");
+ assert!(out.stderr.len() > 100_000);
+ }
+
+ #[test]
+ fn un_programa_inexistente_da_un_error_con_su_nombre() {
+ let err = run("/no/existe/nada", ["x"], Duration::from_secs(1), None).unwrap_err();
+ assert!(err.to_string().contains("/no/existe/nada"), "{err}");
+ }
+
+ #[test]
+ fn un_fallo_conserva_el_codigo_y_el_mensaje() {
+ let out = run("ls", ["/no/existe"], Duration::from_secs(5), None).unwrap();
+ assert!(!out.success());
+ assert!(!out.last_error_line().is_empty());
+ }
+
+ #[test]
+ fn los_metacaracteres_no_los_interpreta_ninguna_shell() {
+ let out = run("echo", ["a; echo b"], Duration::from_secs(5), None).unwrap();
+ assert_eq!(
+ String::from_utf8_lossy(&out.stdout).trim(),
+ "a; echo b",
+ "el punto y coma debe llegar como texto, no como separador de órdenes"
+ );
+ }
+}
diff --git a/crates/asist-core/src/tools.rs b/crates/asist-core/src/tools.rs
index ac08141..3911985 100644
--- a/crates/asist-core/src/tools.rs
+++ b/crates/asist-core/src/tools.rs
@@ -177,7 +177,8 @@ impl ToolRegistry {
pub mod builtin {
use super::*;
- use std::process::{Command, Stdio};
+ use std::path::Path;
+ use std::process::Command;
/// 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.
@@ -361,40 +362,14 @@ pub mod builtin {
// 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 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 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();
+ body = output.stderr.trim().to_string();
}
// La salida se va a leer en voz alta: pasado cierto punto sólo
// sirve para aburrir a quien escucha.
@@ -402,10 +377,10 @@ pub mod builtin {
if body.chars().count() > MAX {
body = body.chars().take(MAX).collect::<String>() + "… (salida recortada)";
}
- if !output.status.success() {
+ if !output.success() {
return Ok(format!(
"la orden terminó con código {}: {body}",
- output.status.code().unwrap_or(-1)
+ output.status.unwrap_or(-1)
));
}
Ok(if body.is_empty() {
@@ -520,7 +495,12 @@ mod tests {
let err = shell
.call(&json!({ "programa": "sleep", "argumentos": ["30"] }))
.unwrap_err();
- assert!(err.to_string().contains("plazo"), "{err}");
+ let err = err.to_string();
+ assert!(err.contains("tardó más de"), "{err}");
+ assert!(
+ err.contains("sleep"),
+ "el error debe decir qué orden se colgó: {err}"
+ );
}
#[test]
diff --git a/crates/asist-tools/src/camera.rs b/crates/asist-tools/src/camera.rs
index fe41319..bdc2aa0 100644
--- a/crates/asist-tools/src/camera.rs
+++ b/crates/asist-tools/src/camera.rs
@@ -1,56 +1,38 @@
-//! Mirar por la cámara.
-//!
-//! El servidor ya tiene cargado el proyector multimodal, así que el mismo
-//! modelo que conversa puede describir una imagen. La herramienta hace tres
-//! cosas: capturar un fotograma, preguntarle al modelo por él y devolver la
-//! respuesta como texto.
-//!
-//! Que la pregunta viaje hasta la cámara importa: «¿