aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-core/src/tools.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-core/src/tools.rs')
-rw-r--r--crates/asist-core/src/tools.rs158
1 files changed, 79 insertions, 79 deletions
diff --git a/crates/asist-core/src/tools.rs b/crates/asist-core/src/tools.rs
index 3911985..1bf1510 100644
--- a/crates/asist-core/src/tools.rs
+++ b/crates/asist-core/src/tools.rs
@@ -1,10 +1,10 @@
-//! Herramientas que el modelo puede invocar.
+//! Tools the model can call.
//!
-//! 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.
+//! The assistant's extension point. A tool is an object that describes itself
+//! in JSON Schema and knows how to run; the registry translates them to the
+//! chat API `tools` format and dispatches the calls that come back. Adding a
+//! new capability means implementing the trait and registering it, without
+//! touching the orchestrator.
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -15,49 +15,49 @@ use serde_json::{json, Value};
use crate::config::ToolsConfig;
use crate::error::{Error, Result};
-/// Lo que el modelo puede pedir que se haga.
+/// Something the model can ask to be done.
pub trait Tool: Send + Sync {
- /// Identificador que usa el modelo. En minúsculas y sin espacios.
+ /// Identifier the model uses. Lowercase, no spaces.
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.
+ /// What it is for. The model reads it, so it is written for the model:
+ /// concrete and in the conversation language.
fn description(&self) -> &str;
- /// JSON Schema de los argumentos.
+ /// JSON Schema of the arguments.
fn parameters(&self) -> Value;
- /// Ejecuta y devuelve el texto que se le entrega al modelo como resultado.
+ /// Runs and returns the text handed to the model as the result.
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.
+ /// `true` if the tool changes something outside the process. The
+ /// orchestrator announces it aloud before running it.
fn is_side_effecting(&self) -> bool {
false
}
- /// Frase que se pronuncia nada más empezar a ejecutarla.
+ /// Sentence spoken right when it starts running.
///
- /// Existe por una razón de trato, no de adorno: una búsqueda son unos
- /// 2,5 s, la cámara casi 3, y a eso hay que sumarle las dos pasadas del
- /// modelo. Seis segundos de silencio absoluto se leen como que el
- /// asistente se ha colgado. Devuelve `None` la herramienta que responda
- /// al instante, donde el acuse molestaría más que ayudar.
+ /// It exists for the sake of the conversation, not decoration: a search takes
+ /// about 2.5 s, the camera almost 3, and the two model passes come on top.
+ /// Six seconds of total silence read as the assistant having hung. A tool
+ /// that answers instantly returns `None`, where the acknowledgement would
+ /// annoy more than help.
fn acknowledgement(&self) -> Option<&str> {
None
}
}
-/// Una llamada tal y como la pide el modelo.
+/// A call as the model requests it.
#[derive(Debug, Clone)]
pub struct ToolCall {
pub id: String,
pub name: String,
- /// Argumentos en JSON, aún sin validar.
+ /// JSON arguments, not validated yet.
pub arguments: String,
}
-/// Resultado de ejecutarla.
+/// Result of running it.
#[derive(Debug, Clone)]
pub struct ToolOutcome {
pub id: String,
@@ -77,7 +77,7 @@ impl ToolRegistry {
Self::default()
}
- /// Monta el juego de herramientas que pide la configuración.
+ /// Builds the tool set the configuration asks for.
pub fn from_config(config: &ToolsConfig) -> Self {
let mut registry = Self::new();
if !config.enabled {
@@ -106,8 +106,8 @@ impl ToolRegistry {
self.tools.get(name)
}
- /// Descripción en el formato `tools` de la API de chat de OpenAI, que es
- /// el que habla llama-server.
+ /// Description in the OpenAI chat API `tools` format, which is what
+ /// llama-server speaks.
pub fn schema(&self) -> Value {
Value::Array(
self.tools
@@ -126,9 +126,9 @@ impl ToolRegistry {
)
}
- /// 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.
+ /// Runs a call. It never propagates the error upwards: a tool failure is
+ /// handed back to the model as text so it can explain it or retry, instead
+ /// of bringing the turn down.
pub fn dispatch(&self, call: &ToolCall) -> ToolOutcome {
let started = std::time::Instant::now();
let result = match self.tools.get(&call.name) {
@@ -141,8 +141,8 @@ impl ToolRegistry {
}),
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.
+ // Small models sometimes send an empty string instead of «{}»
+ // when the function takes no arguments.
if call.arguments.trim().is_empty() {
Ok(json!({}))
} else {
@@ -180,8 +180,8 @@ pub mod builtin {
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.
+ /// Local date and time. It exists because the model does not know them and
+ /// confidently makes them up, and it doubles as a minimal tool example.
pub struct Clock;
impl Tool for Clock {
@@ -199,11 +199,11 @@ pub mod builtin {
}
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».
+ // `date` avoids pulling in a whole calendar dependency for a single
+ // call, and it honours the system time zone. It is asked for numeric
+ // fields and the names are filled in here: `%A` and `%B` come out in
+ // the locale language, which on this machine is English, and the
+ // assistant would end up saying «Sunday 6 de September».
let out = Command::new("date")
.arg("+%w %-d %-m %Y %H:%M")
.output()
@@ -219,7 +219,7 @@ pub mod builtin {
}
}
- pub(super) const DIAS: [&str; 7] = [
+ pub(super) const WEEKDAYS: [&str; 7] = [
"domingo",
"lunes",
"martes",
@@ -228,7 +228,7 @@ pub mod builtin {
"viernes",
"sábado",
];
- const MESES: [&str; 12] = [
+ const MONTHS: [&str; 12] = [
"enero",
"febrero",
"marzo",
@@ -243,7 +243,7 @@ pub mod builtin {
"diciembre",
];
- /// Compone «domingo 6 de septiembre de 2026, 19:13» a partir de
+ /// Builds «domingo 6 de septiembre de 2026, 19:13» from
/// «0 6 9 2026 19:13».
pub(super) fn format_spanish_date(raw: &str) -> Option<String> {
let mut fields = raw.split_whitespace();
@@ -254,18 +254,18 @@ pub mod builtin {
let time = fields.next()?;
Some(format!(
"{} {day} de {} de {year}, {time}",
- DIAS.get(weekday)?,
- MESES.get(month.checked_sub(1)?)?
+ WEEKDAYS.get(weekday)?,
+ MONTHS.get(month.checked_sub(1)?)?
))
}
- /// Ejecución de órdenes del sistema, restringida por lista blanca.
+ /// System command execution, restricted by an allowlist.
///
- /// 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.
+ /// It is the door through which the assistant goes from talking to acting,
+ /// and also the most delicate one in the project: whatever arrives here comes,
+ /// ultimately, from what the microphone hears. Hence four barriers: off by
+ /// default, an allowlist on the executable, arguments with no shell to
+ /// interpret them, and a deadline.
pub struct Shell {
allowlist: Vec<String>,
timeout: Duration,
@@ -284,8 +284,8 @@ pub mod builtin {
}
fn permitted(&self, program: &str) -> bool {
- // Se compara sólo el nombre del ejecutable: dejar pasar rutas
- // completas invitaría a colar «/tmp/ls».
+ // Only the executable name is compared: letting full paths through
+ // would invite sneaking in «/tmp/ls».
!program.contains('/') && self.allowlist.iter().any(|a| a == program)
}
}
@@ -343,7 +343,7 @@ pub mod builtin {
other => Ok(other.to_string()),
})
.collect::<Result<_>>()?,
- // Algunos modelos mandan los argumentos en una sola cadena.
+ // Some models send the arguments as a single string.
Some(Value::String(s)) => s.split_whitespace().map(String::from).collect(),
Some(other) => return Err(fail(format!("«argumentos» inválido: {other}"))),
};
@@ -360,8 +360,8 @@ pub mod builtin {
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.
+ // No shell in between: the arguments go to `execve` as they are,
+ // so a «; rm -rf /» is a literal argument, not another command.
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()))?;
@@ -371,8 +371,8 @@ pub mod builtin {
if body.is_empty() {
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.
+ // The output will be read aloud: past a certain point it only
+ // bores the listener.
const MAX: usize = 2000;
if body.chars().count() > MAX {
body = body.chars().take(MAX).collect::<String>() + "… (salida recortada)";
@@ -398,7 +398,7 @@ mod tests {
use super::*;
#[test]
- fn la_fecha_se_compone_en_espanol_sin_depender_de_la_locale() {
+ fn date_is_built_in_spanish_regardless_of_locale() {
assert_eq!(
format_spanish_date("0 6 9 2026 19:13").unwrap(),
"domingo 6 de septiembre de 2026, 19:13"
@@ -410,28 +410,28 @@ mod tests {
}
#[test]
- fn una_salida_de_date_inesperada_no_provoca_un_panico() {
+ fn unexpected_date_output_does_not_panic() {
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"
+ "weekday out of range"
);
assert!(
format_spanish_date("0 6 13 2026 19:13").is_none(),
- "mes fuera de rango"
+ "month out of range"
);
assert!(
format_spanish_date("0 6 0 2026 19:13").is_none(),
- "mes cero"
+ "month zero"
);
}
#[test]
- fn la_herramienta_de_hora_devuelve_algo_en_espanol() {
+ fn time_tool_returns_spanish_text() {
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}»"
+ super::builtin::WEEKDAYS.iter().any(|d| out.starts_with(d)),
+ "expected a Spanish weekday, got «{out}»"
);
}
@@ -449,7 +449,7 @@ mod tests {
}
#[test]
- fn la_lista_blanca_deja_pasar_lo_autorizado() {
+ fn allowlist_lets_allowed_commands_through() {
let shell = Shell::new(&shell_config());
let out = shell
.call(&json!({ "programa": "echo", "argumentos": ["hola"] }))
@@ -458,39 +458,39 @@ mod tests {
}
#[test]
- fn lo_no_autorizado_se_rechaza() {
+ fn unauthorized_commands_are_rejected() {
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"
+ "«rm» was not in the list and should have been rejected"
);
}
#[test]
- fn una_ruta_absoluta_no_esquiva_la_lista_blanca() {
+ fn absolute_path_does_not_bypass_the_allowlist() {
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"
+ "«/bin/echo» must be rejected: otherwise any binary gets in by giving a path"
);
}
#[test]
- fn no_hay_shell_que_interprete_los_metacaracteres() {
- // Si esto llegara a una shell, se ejecutarían dos órdenes.
+ fn no_shell_interprets_the_metacharacters() {
+ // If this reached a shell, two commands would run.
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"
+ "the semicolon must be text, not a separator"
);
}
#[test]
- fn una_orden_colgada_se_mata_al_vencer_el_plazo() {
+ fn hung_command_is_killed_at_the_deadline() {
let shell = Shell::new(&shell_config());
let err = shell
.call(&json!({ "programa": "sleep", "argumentos": ["30"] }))
@@ -499,12 +499,12 @@ mod tests {
assert!(err.contains("tardó más de"), "{err}");
assert!(
err.contains("sleep"),
- "el error debe decir qué orden se colgó: {err}"
+ "the error must say which command hung: {err}"
);
}
#[test]
- fn la_simulacion_no_ejecuta_nada() {
+ fn dry_run_executes_nothing() {
let mut config = shell_config();
config.shell_dry_run = true;
let shell = Shell::new(&config);
@@ -515,14 +515,14 @@ mod tests {
}
#[test]
- fn la_shell_no_se_registra_si_la_configuracion_no_la_pide() {
+ fn shell_is_not_registered_unless_configured() {
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() {
+ fn unknown_tool_returns_an_error_to_the_model_without_breaking_the_turn() {
let registry = ToolRegistry::from_config(&ToolsConfig::default());
let outcome = registry.dispatch(&ToolCall {
id: "1".into(),
@@ -534,7 +534,7 @@ mod tests {
}
#[test]
- fn los_argumentos_vacios_valen_como_objeto_vacio() {
+ fn empty_arguments_count_as_an_empty_object() {
let registry = ToolRegistry::from_config(&ToolsConfig::default());
let outcome = registry.dispatch(&ToolCall {
id: "1".into(),
@@ -545,7 +545,7 @@ mod tests {
}
#[test]
- fn el_esquema_sale_en_el_formato_de_la_api_de_chat() {
+ fn schema_uses_the_chat_api_format() {
let registry = ToolRegistry::from_config(&ToolsConfig::default());
let schema = registry.schema();
let first = &schema.as_array().unwrap()[0];