tawasuyu

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 Definitions

⎇ main crates/takana-agent/src/translator_claude.rs Rust · 475 lines · 17.6 KB History Who touched what raw

takana: las variables de entorno leen las dos formas, gana la nueva 1c3e18516 · Sergio ·

1//! Traductor LLM real contra la Claude API (anthropic-version `2023-06-01`).
2//!
3//! Implementa [`IntentTranslator`] exactamente como el `MockTranslator`, así que el bucle
4//! del [`Orchestrator`](crate::Orchestrator) no cambia. Lo que ofrece de extra es que el
5//! contenido de la intención NL llega a un modelo real (Claude Opus 4.8 por defecto) que
6//! produce el `.swm` directamente — no se requiere catálogo pre-armado.
7//!
8//! Diseño:
9//!
10//! - **Síncrono** y mínimo: usamos `ureq` (gated detrás del feature `llm-claude`) porque
11//!   el cliente del bus también es síncrono. Tokio entraría sólo si el resto del crate lo
12//!   pidiera.
13//! - **HTTP inyectable**: el trait [`HttpClient`] permite tests sin red contra una fake
14//!   que devuelve respuestas pre-armadas y verifica el cuerpo enviado.
15//! - **Modelo por defecto**: `claude-opus-4-8`, con adaptive thinking activado y
16//!   `effort: "high"`. Cambiar el modelo es modificar el `ClaudeConfig`.
17//! - **Schema enforcement**: el modelo recibe un system prompt que documenta el shape
18//!   exacto del `Swm` con ejemplos; pedimos respuesta JSON pura. El parser local
19//!   (`serde_json::from_str::<Swm>`) actúa como gate adicional, y `verify_schema()`
20//!   se llama antes de devolver. Si el modelo divaga, el caller obtiene un
21//!   `TranslateError::Model` con el cuerpo bruto para debugging.
22//!
23//! El bucle agéntico (con o sin auto-reparación) reusa el mismo `Orchestrator`. La única
24//! diferencia es la construcción del traductor:
25//!
26//! ```ignore
27//! let cfg = ClaudeConfig::from_env()?;
28//! let t = ClaudeTranslator::new(cfg);
29//! let orch = Orchestrator::new(t, base, apply, compile);
30//! orch.run("instala grep con regex Perl por defecto")?;
31//! ```
32
33use crate::translator::{IntentTranslator, SystemContext, TranslateError};
34use takana_core::Swm;
35
36/// Inyección HTTP. La implementación por defecto ([`UreqHttp`]) usa `ureq` síncrono;
37/// los tests pasan una fake.
38pub trait HttpClient: Send + Sync {
39    fn post_json(
40        &self,
41        url: &str,
42        headers: &[(&str, &str)],
43        body: &str,
44    ) -> Result<String, TranslateError>;
45}
46
47#[cfg(feature = "llm-claude")]
48pub struct UreqHttp;
49
50#[cfg(feature = "llm-claude")]
51impl HttpClient for UreqHttp {
52    fn post_json(
53        &self,
54        url: &str,
55        headers: &[(&str, &str)],
56        body: &str,
57    ) -> Result<String, TranslateError> {
58        let mut req = ureq::post(url);
59        for (k, v) in headers {
60            req = req.set(k, v);
61        }
62        let resp = req
63            .send_string(body)
64            .map_err(|e| TranslateError::Model(format!("http: {e}")))?;
65        resp.into_string()
66            .map_err(|e| TranslateError::Model(format!("body: {e}")))
67    }
68}
69
70#[derive(Debug, Clone)]
71pub struct ClaudeConfig {
72    pub api_key: String,
73    pub model: String,
74    pub max_tokens: u32,
75    pub base_url: String,
76    /// Nivel de esfuerzo del modelo (`low|medium|high|max`). Default `high`.
77    pub effort: String,
78}
79
80impl ClaudeConfig {
81    /// Construye desde env vars: `ANTHROPIC_API_KEY` (obligatoria), `TAKANA_LLM_MODEL`,
82    /// `TAKANA_LLM_BASE_URL`, `TAKANA_LLM_EFFORT`.
83    pub fn from_env() -> Result<Self, TranslateError> {
84        let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| {
85            TranslateError::Model(
86                "ANTHROPIC_API_KEY no está en el entorno; exporta tu key o usa MockTranslator"
87                    .into(),
88            )
89        })?;
90        Ok(Self {
91            api_key,
92            model: takana_core::env::var("TAKANA_LLM_MODEL")
93                .unwrap_or_else(|| "claude-opus-4-8".into()),
94            max_tokens: 8192,
95            base_url: takana_core::env::var("TAKANA_LLM_BASE_URL")
96                .unwrap_or_else(|| "https://api.anthropic.com".into()),
97            effort: takana_core::env::var("TAKANA_LLM_EFFORT").unwrap_or_else(|| "high".into()),
98        })
99    }
100}
101
102pub struct ClaudeTranslator {
103    cfg: ClaudeConfig,
104    http: Box<dyn HttpClient>,
105}
106
107impl ClaudeTranslator {
108    /// Constructor por defecto: usa [`UreqHttp`] (sólo disponible con feature `llm-claude`).
109    #[cfg(feature = "llm-claude")]
110    pub fn new(cfg: ClaudeConfig) -> Self {
111        Self { cfg, http: Box::new(UreqHttp) }
112    }
113
114    /// Constructor con HTTP inyectado. Útil para tests y para apuntar a un proxy/mock.
115    pub fn with_http(cfg: ClaudeConfig, http: Box<dyn HttpClient>) -> Self {
116        Self { cfg, http }
117    }
118}
119
120const SYSTEM_PROMPT: &str = r#"Eres un traductor que convierte intenciones humanas en lenguaje natural a manifiestos `.swm` (Software Mutación) del sistema hammer. El receptor parseará tu respuesta con serde_json (Rust) y rechazará cualquier output que no sea JSON puro válido.
121
122# Shape del Swm (versión 1)
123
124```json
125{
126  "swm_version": 1,
127  "base": {
128    "distro_version": "<copia el distro_version del SystemContext>",
129    "pins": {}
130  },
131  "mutations": [ ... ]
132}
133```
134
135# Variantes de mutación
136
1371. config_edit — edita un archivo de texto vía unified diff.
138   {"type":"config_edit","file":"/abs/path","inline_diff":"- old line\n+ new line\n"}
139   - `file` debe ser ruta absoluta.
140   - `inline_diff` usa el formato de diff unified clásico (líneas `-`, `+` y espacio).
141
1422. file_drop — coloca un archivo en el sistema.
143   {"type":"file_drop","path":"/abs/path","content_hash":"b3:<hex>","content_b64":"<base64>"}
144   - `content_hash` es BLAKE3 del contenido descodificado, con el prefijo `b3:`.
145   - SOLO emite `file_drop` si puedes calcular el hash correcto. Si no, salta esta variante.
146
1473. source_patch — compila desde fuente y la inyecta.
148   {"type":"source_patch","repo":"git://...","commit":"<sha>","build":{"compiler":"zig-cc","target":"x86_64-linux-musl","link":"static","flags":[]},"target_bin":"/usr/bin/foo"}
149   - Origen git (repo+commit) O tarball (tarball+sha256), exactamente uno; p. ej.
150     {"type":"source_patch","tarball":"https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz","sha256":"<hex>","build":{...},"target_bin":"/usr/bin/grep"}
151   - `target_bin` ruta absoluta del binario producido.
152
1534. init_rule — registra un servicio.
154   {"type":"init_rule","action":"start","service":"nginx","command":"/usr/sbin/nginx -g 'daemon off;'"}
155
156# Reglas
157
158- TODAS las rutas son absolutas (comienzan con `/`).
159- Copia `distro_version` y `pins` desde el SystemContext que te pase el usuario.
160- Si la intención es ambigua o requiere información que no tienes (p. ej. el hash de un binario que no puedes computar), prefiere `config_edit` o `source_patch` sobre `file_drop`.
161- Responde EXCLUSIVAMENTE con el JSON del Swm. Sin texto antes, sin texto después, sin markdown, sin backticks. La primera línea debe ser `{` y la última `}`.
162"#;
163
164impl IntentTranslator for ClaudeTranslator {
165    fn translate(&self, intent: &str, ctx: &SystemContext) -> Result<Swm, TranslateError> {
166        let body = build_request_body(&self.cfg, intent, ctx)?;
167        let url = format!(
168            "{}/v1/messages",
169            self.cfg.base_url.trim_end_matches('/')
170        );
171        let auth = format!("{}", self.cfg.api_key);
172        let headers: [(&str, &str); 3] = [
173            ("x-api-key", auth.as_str()),
174            ("anthropic-version", "2023-06-01"),
175            ("content-type", "application/json"),
176        ];
177        let raw = self.http.post_json(&url, &headers, &body)?;
178        parse_response(&raw)
179    }
180}
181
182fn build_request_body(
183    cfg: &ClaudeConfig,
184    intent: &str,
185    ctx: &SystemContext,
186) -> Result<String, TranslateError> {
187    let ctx_json = serde_json::json!({
188        "distro_version": ctx.base.distro_version,
189        "pins": ctx.base.pins,
190        "extras": ctx.extras,
191    });
192    let user_msg = format!(
193        "SystemContext:\n{}\n\nIntención del humano:\n{}\n\nRecuerda: responde sólo con el JSON del Swm.",
194        serde_json::to_string_pretty(&ctx_json).unwrap_or_else(|_| "{}".into()),
195        intent,
196    );
197    let body = serde_json::json!({
198        "model": cfg.model,
199        "max_tokens": cfg.max_tokens,
200        "system": SYSTEM_PROMPT,
201        "thinking": { "type": "adaptive" },
202        "output_config": { "effort": cfg.effort },
203        "messages": [
204            { "role": "user", "content": user_msg }
205        ]
206    });
207    serde_json::to_string(&body).map_err(|e| TranslateError::Model(format!("request body: {e}")))
208}
209
210fn parse_response(raw: &str) -> Result<Swm, TranslateError> {
211    let v: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
212        TranslateError::Model(format!("respuesta no es JSON ({e}): {raw}"))
213    })?;
214    // Error envelope de la API: { "type": "error", "error": { "type": "...", "message": "..." } }
215    if v.get("type").and_then(|t| t.as_str()) == Some("error") {
216        let err = v
217            .get("error")
218            .map(|e| e.to_string())
219            .unwrap_or_else(|| "error sin detalle".into());
220        return Err(TranslateError::Model(format!("API: {err}")));
221    }
222    // stop_reason "refusal" ⇒ el modelo rechazó. Lo reportamos como Model.
223    if v.get("stop_reason").and_then(|s| s.as_str()) == Some("refusal") {
224        let det = v
225            .get("stop_details")
226            .map(|d| d.to_string())
227            .unwrap_or_else(|| "sin stop_details".into());
228        return Err(TranslateError::Model(format!("refusal: {det}")));
229    }
230    let text = extract_first_text(&v)?;
231    let cleaned = strip_code_fence(&text);
232    let swm: Swm = serde_json::from_str(cleaned).map_err(|e| {
233        TranslateError::Model(format!(
234            "JSON del modelo no parsea como Swm ({e}). Texto:\n{text}"
235        ))
236    })?;
237    swm.verify_schema()
238        .map_err(|e| TranslateError::Model(format!("schema del Swm: {e}")))?;
239    Ok(swm)
240}
241
242fn extract_first_text(v: &serde_json::Value) -> Result<String, TranslateError> {
243    let content = v
244        .get("content")
245        .and_then(|c| c.as_array())
246        .ok_or_else(|| TranslateError::Model(format!("'content' ausente o no array: {v}")))?;
247    for block in content {
248        if block.get("type").and_then(|t| t.as_str()) == Some("text") {
249            if let Some(s) = block.get("text").and_then(|t| t.as_str()) {
250                return Ok(s.to_string());
251            }
252        }
253    }
254    Err(TranslateError::Model(format!(
255        "no encontré un bloque de texto en content: {v}"
256    )))
257}
258
259/// Limpia el típico envoltorio ```json ... ```. Best-effort: si no encuentra fence,
260/// devuelve el texto sin cambios. También recorta whitespace de los extremos.
261fn strip_code_fence(s: &str) -> &str {
262    let trimmed = s.trim();
263    let inside = trimmed
264        .strip_prefix("```json")
265        .or_else(|| trimmed.strip_prefix("```"))
266        .map(|rest| {
267            rest.trim_start_matches('\n')
268                .trim_end_matches("```")
269                .trim_end_matches('\n')
270                .trim_end()
271        })
272        .unwrap_or(trimmed);
273    inside.trim()
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use std::collections::BTreeMap;
280    use std::sync::{Arc, Mutex};
281
282    use takana_core::BaseRef;
283
284    /// Fake HTTP que captura el request y devuelve un body pre-armado.
285    struct FakeHttp {
286        captured: Arc<Mutex<Option<(String, Vec<(String, String)>, String)>>>,
287        response: String,
288    }
289
290    impl HttpClient for FakeHttp {
291        fn post_json(
292            &self,
293            url: &str,
294            headers: &[(&str, &str)],
295            body: &str,
296        ) -> Result<String, TranslateError> {
297            *self.captured.lock().unwrap() = Some((
298                url.to_string(),
299                headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
300                body.to_string(),
301            ));
302            Ok(self.response.clone())
303        }
304    }
305
306    fn cfg() -> ClaudeConfig {
307        ClaudeConfig {
308            api_key: "sk-test".into(),
309            model: "claude-opus-4-8".into(),
310            max_tokens: 4096,
311            base_url: "https://api.anthropic.com".into(),
312            effort: "high".into(),
313        }
314    }
315
316    fn empty_ctx() -> SystemContext {
317        SystemContext {
318            base: BaseRef {
319                distro_version: "2026-06-06".into(),
320                pins: BTreeMap::new(),
321            },
322            extras: serde_json::Value::Null,
323        }
324    }
325
326    fn ok_response(text: &str) -> String {
327        serde_json::json!({
328            "id": "msg_test",
329            "type": "message",
330            "role": "assistant",
331            "model": "claude-opus-4-8",
332            "content": [{"type": "text", "text": text}],
333            "stop_reason": "end_turn",
334            "usage": {"input_tokens": 1, "output_tokens": 1}
335        })
336        .to_string()
337    }
338
339    fn swm_text() -> String {
340        let s = serde_json::json!({
341            "swm_version": 1,
342            "base": {"distro_version": "2026-06-06", "pins": {}},
343            "mutations": [{
344                "type": "config_edit",
345                "file": "/etc/network.conf",
346                "inline_diff": "- DHCP=yes\n+ STATIC=1\n"
347            }]
348        });
349        serde_json::to_string(&s).unwrap()
350    }
351
352    #[test]
353    fn happy_path_emits_v1_messages_post_with_correct_headers() {
354        let captured = Arc::new(Mutex::new(None));
355        let http = FakeHttp {
356            captured: captured.clone(),
357            response: ok_response(&swm_text()),
358        };
359        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
360        let swm = t.translate("set static IP", &empty_ctx()).unwrap();
361        assert_eq!(swm.mutations.len(), 1);
362
363        let (url, headers, body) = captured.lock().unwrap().clone().unwrap();
364        assert_eq!(url, "https://api.anthropic.com/v1/messages");
365        assert!(headers.iter().any(|(k, v)| k == "x-api-key" && v == "sk-test"));
366        assert!(headers
367            .iter()
368            .any(|(k, v)| k == "anthropic-version" && v == "2023-06-01"));
369        assert!(headers
370            .iter()
371            .any(|(k, v)| k == "content-type" && v == "application/json"));
372        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
373        assert_eq!(parsed["model"], "claude-opus-4-8");
374        assert_eq!(parsed["thinking"]["type"], "adaptive");
375        assert_eq!(parsed["output_config"]["effort"], "high");
376        let user_msg = parsed["messages"][0]["content"].as_str().unwrap();
377        assert!(user_msg.contains("set static IP"), "{user_msg}");
378        assert!(user_msg.contains("2026-06-06"), "{user_msg}");
379    }
380
381    #[test]
382    fn unwraps_markdown_code_fence() {
383        let fenced = format!("```json\n{}\n```", swm_text());
384        let http = FakeHttp {
385            captured: Arc::new(Mutex::new(None)),
386            response: ok_response(&fenced),
387        };
388        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
389        let swm = t.translate("x", &empty_ctx()).unwrap();
390        assert_eq!(swm.mutations.len(), 1);
391    }
392
393    #[test]
394    fn refusal_surfaces_as_model_error() {
395        let resp = serde_json::json!({
396            "id": "msg_x",
397            "type": "message",
398            "content": [],
399            "stop_reason": "refusal",
400            "stop_details": {"category": "cyber", "explanation": "no"},
401            "usage": {"input_tokens": 1, "output_tokens": 0}
402        });
403        let http = FakeHttp {
404            captured: Arc::new(Mutex::new(None)),
405            response: resp.to_string(),
406        };
407        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
408        let err = t.translate("x", &empty_ctx()).unwrap_err();
409        match err {
410            TranslateError::Model(m) => assert!(m.contains("refusal"), "{m}"),
411            other => panic!("esperaba Model, llegó {other:?}"),
412        }
413    }
414
415    #[test]
416    fn api_error_envelope_is_reported() {
417        let resp = serde_json::json!({
418            "type": "error",
419            "error": {"type": "invalid_request_error", "message": "bad model"}
420        });
421        let http = FakeHttp {
422            captured: Arc::new(Mutex::new(None)),
423            response: resp.to_string(),
424        };
425        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
426        let err = t.translate("x", &empty_ctx()).unwrap_err();
427        match err {
428            TranslateError::Model(m) => assert!(m.contains("invalid_request_error"), "{m}"),
429            other => panic!("esperaba Model, llegó {other:?}"),
430        }
431    }
432
433    #[test]
434    fn malformed_swm_surfaces_as_model_error() {
435        let resp = ok_response("{\"not\": \"a swm\"}");
436        let http = FakeHttp {
437            captured: Arc::new(Mutex::new(None)),
438            response: resp,
439        };
440        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
441        let err = t.translate("x", &empty_ctx()).unwrap_err();
442        match err {
443            TranslateError::Model(m) => assert!(m.contains("Swm"), "{m}"),
444            other => panic!("esperaba Model, llegó {other:?}"),
445        }
446    }
447
448    #[test]
449    fn schema_verify_catches_bad_swm() {
450        // swm_version inválida: el JSON parsea pero verify_schema rechaza.
451        let bad = serde_json::json!({
452            "swm_version": 9,
453            "base": {"distro_version": "x", "pins": {}},
454            "mutations": []
455        });
456        let http = FakeHttp {
457            captured: Arc::new(Mutex::new(None)),
458            response: ok_response(&bad.to_string()),
459        };
460        let t = ClaudeTranslator::with_http(cfg(), Box::new(http));
461        let err = t.translate("x", &empty_ctx()).unwrap_err();
462        match err {
463            TranslateError::Model(m) => assert!(m.contains("swm_version"), "{m}"),
464            other => panic!("esperaba Model, llegó {other:?}"),
465        }
466    }
467
468    #[test]
469    fn strip_code_fence_handles_variants() {
470        assert_eq!(strip_code_fence("```json\n{\"a\":1}\n```"), "{\"a\":1}");
471        assert_eq!(strip_code_fence("```\n{\"a\":1}\n```"), "{\"a\":1}");
472        assert_eq!(strip_code_fence("{\"a\":1}"), "{\"a\":1}");
473        assert_eq!(strip_code_fence("  {\"a\":1}\n\n"), "{\"a\":1}");
474    }
475}