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…
takana: las variables de entorno leen las dos formas, gana la nueva
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}