gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ 89ecd54135a72e486f94df045d191d231b253638 crates/hammer-agent/src/translator.rs Rust · 278 siq'ikuna · 9.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

Fase 6 — bucle agéntico: hammer-agent (cliente + translator + orchestrator) y hammer ai 89ecd5413 · Sergio ·

1//! Traductor de intención (NL) → `.swm`. Ver `docs/08-ai-integration.md` §5.
2//!
3//! Contrato del trait: dado un texto humano + un `SystemContext` (lo que el orquestador
4//! pudo recabar vía QUERY: pins, herramientas presentes, distro_version, etc.), producir un
5//! `Swm` válido.
6//!
7//! Phase 6 entrega un [`MockTranslator`] que mapea intentos exactos contra un catálogo
8//! YAML de SWMs pre-armados. Eso permite probar el bucle plan→build→try→verify→propose en
9//! tests y en el host del autor sin LLM. La integración con un modelo real (Claude API,
10//! local llama, etc.) se conecta detrás de este mismo trait — el bucle no cambia.
11
12use std::collections::HashMap;
13use std::path::Path;
14
15use hammer_core::{BaseRef, Swm};
16
17#[derive(Debug, thiserror::Error)]
18pub enum TranslateError {
19    #[error("intent no reconocida en el catálogo: '{0}'")]
20    UnknownIntent(String),
21    #[error("io: {0}")]
22    Io(#[from] std::io::Error),
23    #[error("catálogo inválido: {0}")]
24    Catalog(String),
25    #[error("modelo: {0}")]
26    Model(String),
27}
28
29/// Lo que el orquestador le pasa al traductor para contextualizar la intención. Un LLM
30/// usaría esto para decidir, p. ej., "¿el sistema ya tiene grep 3.12? entonces no necesito
31/// recompilar; sólo edito la config". Para el `MockTranslator` es decorativo.
32#[derive(Debug, Clone)]
33pub struct SystemContext {
34    pub base: BaseRef,
35    /// Información libre que el orquestador haya recolectado (rutas y metadatos de archivos
36    /// referenciados por el intent, salida de QUERY, etc.). Estructura JSON arbitraria.
37    pub extras: serde_json::Value,
38}
39
40pub trait IntentTranslator {
41    fn translate(&self, intent: &str, ctx: &SystemContext) -> Result<Swm, TranslateError>;
42}
43
44/// Catálogo intento→.swm leído de un YAML:
45///
46/// ```yaml
47/// version: 1
48/// intents:
49///   - intent: "set static IP 192.168.1.100"
50///     swm_path: "swms/static-ip.swm"   # ruta relativa al directorio del catálogo
51///     # OR
52///     swm_inline: { swm_version: 1, ... }
53/// ```
54///
55/// El propósito es tener un "test cassette": el agente humano captura una intención que la
56/// IA real ya resolvió, congela el `.swm` resultante, y a partir de ahí los CI/tests del
57/// bucle agéntico son reproducibles.
58#[derive(Debug, Clone, serde::Deserialize)]
59pub struct IntentCatalog {
60    #[serde(default = "default_version")]
61    pub version: u32,
62    pub intents: Vec<CatalogEntry>,
63}
64
65#[derive(Debug, Clone, serde::Deserialize)]
66pub struct CatalogEntry {
67    pub intent: String,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub swm_path: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub swm_inline: Option<Swm>,
72}
73
74fn default_version() -> u32 {
75    1
76}
77
78impl IntentCatalog {
79    pub fn load_from_path(path: &Path) -> Result<Self, TranslateError> {
80        let text = std::fs::read_to_string(path)?;
81        let cat: IntentCatalog = serde_yaml::from_str(&text)
82            .map_err(|e| TranslateError::Catalog(e.to_string()))?;
83        if cat.version != 1 {
84            return Err(TranslateError::Catalog(format!(
85                "version del catálogo no soportada: {} (esperada 1)",
86                cat.version
87            )));
88        }
89        Ok(cat)
90    }
91
92    /// Resuelve un `intent` exacto contra el catálogo. Las entradas `swm_path` se resuelven
93    /// relativas al `base_dir` (típicamente el directorio del catálogo).
94    pub fn resolve(&self, intent: &str, base_dir: &Path) -> Result<Swm, TranslateError> {
95        let entry = self
96            .intents
97            .iter()
98            .find(|e| e.intent == intent)
99            .ok_or_else(|| TranslateError::UnknownIntent(intent.into()))?;
100        if let Some(s) = &entry.swm_inline {
101            return Ok(s.clone());
102        }
103        if let Some(p) = &entry.swm_path {
104            let full = base_dir.join(p);
105            let text = std::fs::read_to_string(&full)?;
106            return Swm::from_yaml(&text)
107                .map_err(|e| TranslateError::Catalog(format!("swm {}: {e}", full.display())));
108        }
109        Err(TranslateError::Catalog(format!(
110            "entry '{intent}' no declara swm_path ni swm_inline"
111        )))
112    }
113}
114
115/// Traductor offline: matchea la intención exacta contra un mapa precargado.
116#[derive(Debug, Default)]
117pub struct MockTranslator {
118    map: HashMap<String, Swm>,
119}
120
121impl MockTranslator {
122    pub fn empty() -> Self {
123        Self { map: HashMap::new() }
124    }
125
126    pub fn from_catalog(cat: &IntentCatalog, base_dir: &Path) -> Result<Self, TranslateError> {
127        let mut map = HashMap::new();
128        for entry in &cat.intents {
129            let swm = match (&entry.swm_inline, &entry.swm_path) {
130                (Some(s), None) => s.clone(),
131                (None, Some(p)) => {
132                    let full = base_dir.join(p);
133                    let text = std::fs::read_to_string(&full)?;
134                    Swm::from_yaml(&text).map_err(|e| {
135                        TranslateError::Catalog(format!("swm {}: {e}", full.display()))
136                    })?
137                }
138                _ => {
139                    return Err(TranslateError::Catalog(format!(
140                        "entry '{}' debe declarar exactamente uno de swm_inline/swm_path",
141                        entry.intent
142                    )));
143                }
144            };
145            map.insert(entry.intent.clone(), swm);
146        }
147        Ok(Self { map })
148    }
149
150    pub fn with_intent(mut self, intent: impl Into<String>, swm: Swm) -> Self {
151        self.map.insert(intent.into(), swm);
152        self
153    }
154}
155
156impl IntentTranslator for MockTranslator {
157    fn translate(&self, intent: &str, _ctx: &SystemContext) -> Result<Swm, TranslateError> {
158        self.map
159            .get(intent)
160            .cloned()
161            .ok_or_else(|| TranslateError::UnknownIntent(intent.into()))
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use hammer_core::swm::{Base, Mutation};
169    use std::collections::BTreeMap;
170
171    fn empty_ctx() -> SystemContext {
172        SystemContext {
173            base: BaseRef {
174                distro_version: "2026-06-06".into(),
175                pins: BTreeMap::new(),
176            },
177            extras: serde_json::Value::Null,
178        }
179    }
180
181    fn swm_demo() -> Swm {
182        Swm {
183            swm_version: 1,
184            base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
185            mutations: vec![Mutation::ConfigEdit {
186                file: "/etc/network.conf".into(),
187                inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(),
188            }],
189            signature: None,
190        }
191    }
192
193    #[test]
194    fn mock_translator_returns_known_swm() {
195        let t = MockTranslator::empty().with_intent("set static ip", swm_demo());
196        let s = t.translate("set static ip", &empty_ctx()).unwrap();
197        assert_eq!(s.mutations.len(), 1);
198    }
199
200    #[test]
201    fn mock_translator_rejects_unknown() {
202        let t = MockTranslator::empty();
203        let err = t.translate("paint the wall green", &empty_ctx()).unwrap_err();
204        assert!(matches!(err, TranslateError::UnknownIntent(_)));
205    }
206
207    #[test]
208    fn catalog_load_inline() {
209        let d = tempfile::tempdir().unwrap();
210        let cat_path = d.path().join("catalog.yaml");
211        std::fs::write(
212            &cat_path,
213            r#"
214version: 1
215intents:
216  - intent: "set static ip"
217    swm_inline:
218      swm_version: 1
219      base:
220        distro_version: "2026-06-06"
221      mutations:
222        - type: config_edit
223          file: "/etc/network.conf"
224          inline_diff: "- DHCP=yes\n+ STATIC=1\n"
225"#,
226        )
227        .unwrap();
228        let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
229        let t = MockTranslator::from_catalog(&cat, d.path()).unwrap();
230        let s = t.translate("set static ip", &empty_ctx()).unwrap();
231        assert_eq!(s.mutations.len(), 1);
232    }
233
234    #[test]
235    fn catalog_load_from_swm_path() {
236        let d = tempfile::tempdir().unwrap();
237        let swm_path = d.path().join("net.swm");
238        std::fs::write(&swm_path, swm_demo().to_yaml().unwrap()).unwrap();
239        let cat_path = d.path().join("catalog.yaml");
240        std::fs::write(
241            &cat_path,
242            r#"
243version: 1
244intents:
245  - intent: "set static ip"
246    swm_path: "net.swm"
247"#,
248        )
249        .unwrap();
250        let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
251        let t = MockTranslator::from_catalog(&cat, d.path()).unwrap();
252        let s = t.translate("set static ip", &empty_ctx()).unwrap();
253        assert_eq!(s.mutations.len(), 1);
254    }
255
256    #[test]
257    fn catalog_rejects_unsupported_version() {
258        let d = tempfile::tempdir().unwrap();
259        let cat_path = d.path().join("catalog.yaml");
260        std::fs::write(&cat_path, "version: 9\nintents: []\n").unwrap();
261        let err = IntentCatalog::load_from_path(&cat_path).unwrap_err();
262        assert!(matches!(err, TranslateError::Catalog(_)));
263    }
264
265    #[test]
266    fn catalog_rejects_entry_without_swm() {
267        let d = tempfile::tempdir().unwrap();
268        let cat_path = d.path().join("catalog.yaml");
269        std::fs::write(
270            &cat_path,
271            "version: 1\nintents:\n  - intent: empty\n",
272        )
273        .unwrap();
274        let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
275        let err = MockTranslator::from_catalog(&cat, d.path()).unwrap_err();
276        assert!(matches!(err, TranslateError::Catalog(_)));
277    }
278}