1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
|
# 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<String> {
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::<f32>().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.
|