diff --git a/Cargo.lock b/Cargo.lock index 895ad495..22130192 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,23 @@ dependencies = [ "wasip3", ] +[[package]] +name = "hammer-agent" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64", + "hammer-core", + "hammer-journal", + "hammer-overlay", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "thiserror", + "tracing", +] + [[package]] name = "hammer-build" version = "0.0.1" @@ -310,6 +327,7 @@ dependencies = [ "anyhow", "base64", "clap", + "hammer-agent", "hammer-build", "hammer-core", "hammer-journal", diff --git a/Cargo.toml b/Cargo.toml index a0b89b1a..b8af6db6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/hammer-build", "crates/hammer-overlay", "crates/hammer-journal", + "crates/hammer-agent", "crates/hammer-cli", "crates/hammerd", ] @@ -21,6 +22,7 @@ hammer-core = { path = "crates/hammer-core" } hammer-build = { path = "crates/hammer-build" } hammer-overlay = { path = "crates/hammer-overlay" } hammer-journal = { path = "crates/hammer-journal" } +hammer-agent = { path = "crates/hammer-agent" } # shared third-party deps, pinned at the workspace level anyhow = "1" diff --git a/crates/hammer-agent/Cargo.toml b/crates/hammer-agent/Cargo.toml new file mode 100644 index 00000000..8af02c68 --- /dev/null +++ b/crates/hammer-agent/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "hammer-agent" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Cliente del bus + bucle agéntico (plan/build/try/verify/propose) para la IA." + +[dependencies] +hammer-core.workspace = true +hammer-overlay.workspace = true +anyhow.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true +hammer-journal.workspace = true +base64.workspace = true diff --git a/crates/hammer-agent/src/client.rs b/crates/hammer-agent/src/client.rs new file mode 100644 index 00000000..d6eca965 --- /dev/null +++ b/crates/hammer-agent/src/client.rs @@ -0,0 +1,286 @@ +//! `AgentClient`: cliente síncrono del bus de agente. Ver `docs/07-agent-bus.md` §3. +//! +//! Modelo: una conexión = un thread del que llama. El reader thread interno demultiplexa +//! eventos por tipo: respuestas correlativas a un comando se devuelven al caller via +//! `recv_*_event_blocking`; eventos asíncronos (`Modified`, `Crashed`) se acumulan en una +//! cola que el caller drena con `drain_async()`. +//! +//! No hay correlation-id en el protocolo todavía (el SDD 07 no lo exige), así que la +//! correlación se hace por **tipo**: tras un `Compile` esperamos `BuildReady|BuildFailed`, +//! cualquier otra cosa que no sea `Modified`/`Crashed` se reporta como protocol error. + +use std::collections::VecDeque; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline}; + +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("protocolo: {0}")] + Protocol(String), + #[error("timeout esperando {0}")] + Timeout(&'static str), + #[error("el bus devolvió error code={code} msg={msg}")] + Bus { code: String, msg: String }, + #[error("la conexión se cerró")] + Closed, +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone)] +pub struct Welcome { + pub ver: u32, + pub caps: Vec, + pub peer: Peer, +} + +/// Estado compartido entre el reader thread y el caller. +struct Inbox { + /// Eventos asíncronos (Modified/Crashed) en orden de llegada. + async_events: VecDeque, + /// Próximo evento "respuesta" al último comando. Sólo cabe uno a la vez en este + /// cliente síncrono (el caller envía + espera antes del siguiente). + pending: Option, + closed: bool, +} + +#[derive(Clone)] +struct Shared { + inbox: Arc>, + cv: Arc, +} + +pub struct AgentClient { + writer: UnixStream, + shared: Shared, + pub welcome: Welcome, +} + +impl AgentClient { + /// Conecta al socket, hace handshake `Hello` y arranca el reader thread. Devuelve un + /// cliente listo para enviar comandos. + pub fn connect(sock_path: &Path) -> Result { + Self::connect_with_client_name(sock_path, "hammer-agent") + } + + pub fn connect_with_client_name(sock_path: &Path, client: &str) -> Result { + let stream = UnixStream::connect(sock_path)?; + let reader_stream = stream.try_clone()?; + let writer = stream; + + let shared = Shared { + inbox: Arc::new(Mutex::new(Inbox { + async_events: VecDeque::new(), + pending: None, + closed: false, + })), + cv: Arc::new(Condvar::new()), + }; + // Reader thread: drena el socket línea a línea hasta EOF. + let s_for_reader = shared.clone(); + thread::Builder::new() + .name("agent-client-reader".into()) + .spawn(move || reader_loop(reader_stream, s_for_reader)) + .ok(); + + let mut me = Self { + writer, + shared, + welcome: Welcome { + ver: 0, + caps: vec![], + peer: Peer { uid: 0, gid: 0, pid: 0 }, + }, + }; + me.send(&Command::Hello { + ver: hammer_core::proto::PROTOCOL_VERSION, + client: client.to_string(), + })?; + let ev = me.recv_pending(Duration::from_secs(5))?; + match ev { + Event::Welcome { ver, caps, peer } => { + me.welcome = Welcome { ver, caps, peer }; + } + Event::Error { code, msg } => return Err(ClientError::Bus { code, msg }), + other => { + return Err(ClientError::Protocol(format!( + "esperaba Welcome, llegó {other:?}" + ))); + } + } + Ok(me) + } + + /// Bloquea hasta `BuildReady` o `BuildFailed`. `timeout` es el wall-clock máximo — + /// el lab puede tardar varios minutos, así que el default debería ser generoso. + pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result { + self.send(&Command::Compile { recipe })?; + match self.recv_pending(timeout)? { + Event::BuildReady { artifact, .. } => Ok(artifact), + Event::BuildFailed { reason, .. } => Err(ClientError::Bus { + code: "build_failed".into(), + msg: reason, + }), + Event::Error { code, msg } => Err(ClientError::Bus { code, msg }), + other => Err(ClientError::Protocol(format!( + "esperaba BuildReady|BuildFailed, llegó {other:?}" + ))), + } + } + + pub fn inject( + &mut self, + artifact: &str, + target: &str, + overlay: Option<&str>, + timeout: Duration, + ) -> Result { + self.send(&Command::Inject { + artifact: artifact.to_string(), + target: target.to_string(), + overlay: overlay.map(String::from), + })?; + match self.recv_pending(timeout)? { + Event::Injected { files, .. } => Ok(files), + Event::Error { code, msg } => Err(ClientError::Bus { code, msg }), + other => Err(ClientError::Protocol(format!("esperaba Injected, llegó {other:?}"))), + } + } + + pub fn query_file(&mut self, path: &str, timeout: Duration) -> Result { + self.send(&Command::Query { + what: "file".into(), + path: Some(path.to_string()), + name: None, + })?; + match self.recv_pending(timeout)? { + Event::QueryResult { value, .. } => Ok(value), + Event::Error { code, msg } => Err(ClientError::Bus { code, msg }), + other => Err(ClientError::Protocol(format!( + "esperaba QueryResult, llegó {other:?}" + ))), + } + } + + pub fn init(&mut self, cmd: &str, timeout: Duration) -> Result<()> { + self.send(&Command::Init { cmd: cmd.to_string() })?; + match self.recv_pending(timeout)? { + Event::InitAck { .. } => Ok(()), + Event::Error { code, msg } => Err(ClientError::Bus { code, msg }), + other => Err(ClientError::Protocol(format!("esperaba InitAck, llegó {other:?}"))), + } + } + + /// Vacía la cola de eventos asíncronos. No bloquea. + pub fn drain_async(&self) -> Vec { + let Ok(mut g) = self.shared.inbox.lock() else { return vec![] }; + std::mem::take(&mut g.async_events).into_iter().collect() + } + + /// Espera el próximo evento asíncrono (típicamente para verificar que un `Modified` + /// específico haya llegado tras una operación). + pub fn next_async(&self, timeout: Duration) -> Result { + let g = self.shared.inbox.lock().unwrap(); + let (mut g, _) = self + .shared + .cv + .wait_timeout_while(g, timeout, |i| { + !i.closed && i.async_events.is_empty() + }) + .unwrap(); + if let Some(ev) = g.async_events.pop_front() { + Ok(ev) + } else if g.closed { + Err(ClientError::Closed) + } else { + Err(ClientError::Timeout("async event")) + } + } + + fn send(&mut self, cmd: &Command) -> Result<()> { + let line = serde_json::to_string(cmd)?; + self.writer.write_all(line.as_bytes())?; + self.writer.write_all(b"\n")?; + self.writer.flush()?; + Ok(()) + } + + fn recv_pending(&self, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let g = self.shared.inbox.lock().unwrap(); + let (mut g, _) = self + .shared + .cv + .wait_timeout_while(g, timeout, |i| !i.closed && i.pending.is_none()) + .unwrap(); + if let Some(ev) = g.pending.take() { + return Ok(ev); + } + if g.closed { + return Err(ClientError::Closed); + } + // Si el wait expiró exactamente en el momento de un evento, vuelvo a comprobar. + if Instant::now() >= deadline { + return Err(ClientError::Timeout("response")); + } + Err(ClientError::Timeout("response")) + } +} + +fn reader_loop(stream: UnixStream, shared: Shared) { + let reader = BufReader::new(stream); + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + tracing::warn!(error = %e, "agent-client: read error, cerrando"); + break; + } + }; + if line.trim().is_empty() { + continue; + } + let ev: Event = match serde_json::from_str(&line) { + Ok(e) => e, + Err(e) => { + tracing::warn!(error = %e, line = %line, "agent-client: línea no parseable"); + continue; + } + }; + let is_async = matches!( + &ev, + Event::Modified { .. } | Event::Crashed { .. } + ); + let mut g = match shared.inbox.lock() { + Ok(g) => g, + Err(_) => break, + }; + if is_async { + g.async_events.push_back(ev); + } else { + // Si ya hay un pending sin recoger, eso es un bug del caller (envió dos + // comandos antes de leer la primera respuesta). Lo logueamos y reemplazamos. + if g.pending.is_some() { + tracing::warn!( + "agent-client: pending ya tenía respuesta; el caller no la recogió" + ); + } + g.pending = Some(ev); + } + shared.cv.notify_all(); + } + if let Ok(mut g) = shared.inbox.lock() { + g.closed = true; + shared.cv.notify_all(); + } +} diff --git a/crates/hammer-agent/src/lib.rs b/crates/hammer-agent/src/lib.rs new file mode 100644 index 00000000..f14612e3 --- /dev/null +++ b/crates/hammer-agent/src/lib.rs @@ -0,0 +1,46 @@ +//! Cliente del bus de agente + bucle agéntico (Fase 6). Ver `docs/08-ai-integration.md`. +//! +//! Tres piezas: +//! +//! 1. [`client`] — `AgentClient`: envoltorio síncrono de `/run/agent.sock`. Hace el +//! handshake, envía comandos y devuelve eventos. Diseñado para que el orquestador +//! pueda esperar a un `BuildReady` específico tras un `Compile`. +//! 2. [`translator`] — Trait `IntentTranslator` + `MockTranslator`: convierte una intención +//! en lenguaje natural a un `.swm`. El traductor real (LLM) se conecta detrás del trait; +//! el mock cubre los tests y los smoke en el host de desarrollo. +//! 3. [`orchestrator`] — El bucle plan→build→try→apply→verify→propose. Aplica las +//! mutaciones a un overlay (o a un prefix para tests), valida postcondiciones y +//! devuelve un `Proposal` que el humano usa para decidir `commit`. +//! +//! Decisión de diseño: el cliente es **bloqueante** (std::sync), no async. La cadencia +//! agéntica es de comandos/seg, no de RPS; y un cliente sync es 5× menos código y debuggable +//! con un breakpoint normal. Cuando aparezca un caso real de N agentes concurrentes, +//! evaluamos tokio. + +pub mod client; +pub mod orchestrator; +pub mod translator; + +pub use client::{AgentClient, ClientError, Welcome}; +pub use orchestrator::{Orchestrator, Proposal, VerifyCheck}; +pub use translator::{IntentCatalog, IntentTranslator, MockTranslator, SystemContext, TranslateError}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("client: {0}")] + Client(#[from] ClientError), + #[error("translator: {0}")] + Translate(#[from] TranslateError), + #[error("orchestrator: {0}")] + Orchestrate(String), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("overlay: {0}")] + Overlay(#[from] hammer_overlay::Error), + #[error("apply: {0}")] + Apply(#[from] hammer_core::apply::ApplyError), + #[error("core: {0}")] + Core(#[from] hammer_core::Error), +} + +pub type Result = std::result::Result; diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs new file mode 100644 index 00000000..78cd77ff --- /dev/null +++ b/crates/hammer-agent/src/orchestrator.rs @@ -0,0 +1,469 @@ +//! El bucle agéntico. Ver `docs/08-ai-integration.md` §2. +//! +//! `Orchestrator::run(intent)` ejecuta: +//! +//! ```text +//! plan → translator.translate(intent, ctx) → Swm +//! schema → swm.verify_schema() +//! base → swm.verify_base(local) → debe ser Ok +//! build → para cada source_patch: AgentClient.compile() vía el bus (o saltar si --no-bus) +//! try → hammer_overlay::try_overlay() (o usar --prefix) +//! apply → hammer-core::apply para config_edit/file_drop + hydrate para source_patch +//! verify → spot-checks post-condición sobre disco +//! propose → devuelve Proposal con overlay_id + log de checks +//! ``` +//! +//! La IA NUNCA ejecuta `hammer commit`. El humano lo hace tras revisar el `Proposal`. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use hammer_core::apply::{apply_config_edit, apply_file_drop, rebase_path}; +use hammer_core::proto::RecipeInline; +use hammer_core::swm::{Mutation, Swm}; +use hammer_core::BaseRef; +use hammer_overlay::OverlayId; + +use crate::client::AgentClient; +use crate::translator::{IntentTranslator, SystemContext}; +use crate::{Error, Result}; + +/// Cómo aplica el orquestador las mutaciones al sistema. +#[derive(Debug, Clone)] +pub enum ApplyTarget { + /// Abre un overlay sobre los targets por defecto del FHS (requiere root). Las + /// mutaciones caen sobre las rutas absolutas del `.swm`. + Overlay { state_root: PathBuf }, + /// Re-rootea cada path bajo `prefix`. No abre overlay — útil para tests offline y + /// para staging en otro filesystem. + Prefix { prefix: PathBuf }, +} + +/// Cómo se construyen las `source_patch`. `ViaBus` requiere un `hammerd` corriendo; +/// `Skip` ignora las source_patch (apropiado para escenarios sin lab). +#[derive(Debug, Clone)] +pub enum CompileMode { + /// Llama a `Compile` por el bus y espera `BuildReady`. Hidrata localmente desde el + /// store si está configurado. + ViaBus { + sock: PathBuf, + store: PathBuf, + timeout: Duration, + }, + /// Salta cualquier `source_patch`. Se anota en `Proposal.skipped_source_patches`. + Skip, +} + +pub struct Orchestrator { + translator: T, + base: BaseRef, + apply: ApplyTarget, + compile: CompileMode, +} + +impl Orchestrator { + pub fn new(translator: T, base: BaseRef, apply: ApplyTarget, compile: CompileMode) -> Self { + Self { translator, base, apply, compile } + } + + pub fn run(&self, intent: &str) -> Result { + // 1. PLAN + let ctx = SystemContext { + base: self.base.clone(), + extras: serde_json::Value::Null, + }; + let swm = self.translator.translate(intent, &ctx)?; + + // 2. SCHEMA + swm.verify_schema()?; + + // 3. BASE + match swm.verify_base(&self.base) { + hammer_core::BaseCompat::Ok => {} + other => { + return Err(Error::Orchestrate(format!( + "base incompatible: {other:?}" + ))); + } + } + + // 4. TRY (overlay) — recolectamos el id si lo abrimos; en modo Prefix no hay id. + let (overlay_id, prefix): (Option, Option) = match &self.apply { + ApplyTarget::Overlay { state_root } => { + std::fs::create_dir_all(state_root)?; + let id = hammer_overlay::try_overlay(&[], state_root)?; + (Some(id), None) + } + ApplyTarget::Prefix { prefix } => { + std::fs::create_dir_all(prefix)?; + (None, Some(prefix.clone())) + } + }; + + // 5. APPLY — recorremos las mutaciones en orden. En source_patch, dispatch al bus + // o skip; en config_edit/file_drop, primitivas de hammer-core::apply. + let mut applied = AppliedCounts::default(); + let mut checks: Vec = Vec::new(); + let mut skipped_source_patches = 0usize; + + let mut maybe_client: Option = None; + if matches!(self.compile, CompileMode::ViaBus { .. }) + && swm.mutations.iter().any(|m| matches!(m, Mutation::SourcePatch { .. })) + { + if let CompileMode::ViaBus { sock, .. } = &self.compile { + maybe_client = Some(AgentClient::connect(sock).map_err(Error::Client)?); + } + } + + for m in &swm.mutations { + match m { + Mutation::ConfigEdit { file, inline_diff } => { + let target = rebase_path(file, prefix.as_deref()); + apply_config_edit(&target, inline_diff)?; + applied.config_edit += 1; + checks.push(VerifyCheck::pass(format!( + "config_edit aplicado a {}", + target.display() + ))); + } + Mutation::FileDrop { path, content_hash, content_b64, content_url } => { + if let Some(_url) = content_url { + return Err(Error::Orchestrate(format!( + "file_drop con content_url no soportado en Fase 6: {path}" + ))); + } + let b64 = content_b64.as_deref().ok_or_else(|| { + Error::Orchestrate(format!( + "file_drop {path} no trae content_b64 (verify_schema falló)" + )) + })?; + let target = rebase_path(path, prefix.as_deref()); + apply_file_drop(&target, b64, content_hash)?; + applied.file_drop += 1; + checks.push(VerifyCheck::pass(format!( + "file_drop escrito en {} (hash verificado)", + target.display() + ))); + } + Mutation::InitRule { action, service, .. } => { + applied.init_rule += 1; + checks.push(VerifyCheck::warn(format!( + "init_rule '{action} {service}' anotada — pendiente Fase 5+ (supervisor)" + ))); + } + Mutation::SourcePatch { + repo, + commit, + patch, + patch_url: _, + build, + target_bin, + expected_hash, + } => { + let CompileMode::ViaBus { store, timeout, .. } = &self.compile else { + skipped_source_patches += 1; + checks.push(VerifyCheck::warn(format!( + "source_patch {target_bin} omitido (CompileMode::Skip)" + ))); + continue; + }; + let name = Path::new(target_bin) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("swm-bin") + .to_string(); + let recipe = RecipeInline { + name: name.clone(), + repo: repo.clone(), + commit: commit.clone(), + patch: patch.clone(), + compiler: build.compiler.clone(), + target: build.target.clone(), + link: build.link.clone(), + flags: build.flags.clone(), + }; + let client = maybe_client.as_mut().expect("client conectado arriba"); + let artifact = client + .compile(recipe, *timeout) + .map_err(Error::Client)?; + if let Some(expected) = expected_hash { + let want = expected.strip_prefix("b3:").unwrap_or(expected); + let got = artifact.strip_prefix("b3:").unwrap_or(&artifact); + if want != got { + return Err(Error::Orchestrate(format!( + "source_patch {target_bin}: expected_hash no coincide \ + (declarado=b3:{want}, build=b3:{got})" + ))); + } + } + // Hidratamos localmente desde el store (sin pasar por el bus, evita un + // segundo Inject que duplicaría trabajo). + let store = hammer_core::Store::open(store)?; + let artifact_dir = store + .find_by_hash(&artifact) + .map_err(|e| Error::Orchestrate(format!("find_by_hash: {e}")))?; + let into = rebase_path("/", prefix.as_deref()); + let report = hammer_build_run_hydrate(&artifact_dir, &into)?; + let abs = rebase_path(target_bin, prefix.as_deref()); + if !abs.exists() { + return Err(Error::Orchestrate(format!( + "source_patch declara target_bin={target_bin} pero no quedó en {}", + abs.display() + ))); + } + applied.source_patch += 1; + checks.push(VerifyCheck::pass(format!( + "source_patch {target_bin}: build {artifact}, {} archivo(s) hidratado(s)", + report + ))); + } + } + } + + Ok(Proposal { + intent: intent.to_string(), + swm, + overlay_id: overlay_id.map(|o| o.as_str().to_string()), + prefix: prefix.clone(), + applied, + skipped_source_patches, + checks, + }) + } +} + +/// Stub: `hammer-build` no es dependencia directa de `hammer-agent` para no arrastrar el +/// lab a clientes que sólo quieran hablar con el bus. Implementamos la hidratación local +/// con `std::fs::hard_link` directamente porque es trivial. Si el árbol del artefacto crece +/// (symlinks, perms más finos), promovemos a `hammer-build::run_hydrate` añadiendo la dep +/// de manera condicional. +fn hammer_build_run_hydrate(artifact_dir: &Path, target_fhs: &Path) -> Result { + let mut files = 0usize; + walk(artifact_dir, &mut |rel, kind| { + let dst = target_fhs.join(rel); + match kind { + EntryKind::Dir => { + std::fs::create_dir_all(&dst)?; + } + EntryKind::Symlink(target) => { + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + let _ = std::fs::remove_file(&dst); + std::os::unix::fs::symlink(&target, &dst)?; + files += 1; + } + EntryKind::File(src) => { + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + let _ = std::fs::remove_file(&dst); + std::fs::hard_link(&src, &dst).or_else(|_| { + // Cross-fs ⇒ caemos a copia. No optimizamos: hammer-agent espera + // mismo FS; sólo damos el fallback como cinturón de seguridad. + std::fs::copy(&src, &dst).map(|_| ()) + })?; + files += 1; + } + } + Ok::<(), std::io::Error>(()) + })?; + Ok(files) +} + +enum EntryKind { + Dir, + Symlink(PathBuf), + File(PathBuf), +} + +fn walk(root: &Path, f: &mut F) -> std::io::Result<()> +where + F: FnMut(&Path, EntryKind) -> std::io::Result<()>, +{ + fn inner(root: &Path, cur: &Path, f: &mut F) -> std::io::Result<()> + where + F: FnMut(&Path, EntryKind) -> std::io::Result<()>, + { + for entry in std::fs::read_dir(cur)? { + let entry = entry?; + let src = entry.path(); + let rel = src.strip_prefix(root).unwrap(); + let ft = entry.file_type()?; + if ft.is_symlink() { + let target = std::fs::read_link(&src)?; + f(rel, EntryKind::Symlink(target))?; + } else if ft.is_dir() { + f(rel, EntryKind::Dir)?; + inner(root, &src, f)?; + } else if ft.is_file() { + f(rel, EntryKind::File(src.clone()))?; + } + } + Ok(()) + } + if !root.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("artifact_dir no existe: {}", root.display()), + )); + } + inner(root, root, f) +} + +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct AppliedCounts { + pub source_patch: usize, + pub config_edit: usize, + pub init_rule: usize, + pub file_drop: usize, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct VerifyCheck { + pub level: CheckLevel, + pub msg: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CheckLevel { + Pass, + Warn, + Fail, +} + +impl VerifyCheck { + pub fn pass(msg: impl Into) -> Self { + Self { level: CheckLevel::Pass, msg: msg.into() } + } + pub fn warn(msg: impl Into) -> Self { + Self { level: CheckLevel::Warn, msg: msg.into() } + } +} + +/// El resultado del bucle. El humano lo lee para decidir `hammer commit ` (si +/// está en modo Overlay) o `hammer discard`. +#[derive(Debug, Clone, serde::Serialize)] +pub struct Proposal { + pub intent: String, + pub swm: Swm, + /// `Some` si abrimos un overlay. En modo Prefix, `None`. + pub overlay_id: Option, + pub prefix: Option, + pub applied: AppliedCounts, + pub skipped_source_patches: usize, + pub checks: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::translator::MockTranslator; + use hammer_core::swm::{Base, Mutation}; + use std::collections::BTreeMap; + + fn baseref() -> BaseRef { + BaseRef { distro_version: "2026-06-06".into(), pins: BTreeMap::new() } + } + + fn swm_config_only() -> Swm { + Swm { + swm_version: 1, + base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() }, + mutations: vec![Mutation::ConfigEdit { + file: "/etc/network.conf".into(), + inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(), + }], + signature: None, + } + } + + #[test] + fn orchestrator_applies_config_edit_to_prefix() { + let d = tempfile::tempdir().unwrap(); + let prefix = d.path().join("prefix"); + std::fs::create_dir_all(prefix.join("etc")).unwrap(); + std::fs::write(prefix.join("etc/network.conf"), "DHCP=yes\nMTU=1500\n").unwrap(); + + let t = MockTranslator::empty().with_intent("set static ip", swm_config_only()); + let o = Orchestrator::new( + t, + baseref(), + ApplyTarget::Prefix { prefix: prefix.clone() }, + CompileMode::Skip, + ); + let p = o.run("set static ip").unwrap(); + assert_eq!(p.applied.config_edit, 1); + assert!(p.overlay_id.is_none()); + assert_eq!(p.checks.len(), 1); + assert_eq!(p.checks[0].level, CheckLevel::Pass); + + let got = std::fs::read_to_string(prefix.join("etc/network.conf")).unwrap(); + assert_eq!(got, "STATIC=1\nMTU=1500\n"); + } + + #[test] + fn orchestrator_rejects_base_mismatch() { + let mut swm = swm_config_only(); + swm.base.distro_version = "2027-01-01".into(); + let t = MockTranslator::empty().with_intent("x", swm); + let d = tempfile::tempdir().unwrap(); + let o = Orchestrator::new( + t, + baseref(), + ApplyTarget::Prefix { prefix: d.path().to_path_buf() }, + CompileMode::Skip, + ); + let err = o.run("x").unwrap_err().to_string(); + assert!(err.contains("base incompatible"), "{err}"); + } + + #[test] + fn orchestrator_skips_source_patch_with_warn() { + let swm = Swm { + swm_version: 1, + base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() }, + mutations: vec![Mutation::SourcePatch { + repo: "git://x".into(), + commit: "abc".into(), + patch: None, + patch_url: None, + build: hammer_core::swm::SwmBuild { + compiler: "zig-cc".into(), + target: "x86_64-linux-musl".into(), + link: "static".into(), + flags: vec![], + }, + target_bin: "/bin/x".into(), + expected_hash: None, + }], + signature: None, + }; + let t = MockTranslator::empty().with_intent("compile x", swm); + let d = tempfile::tempdir().unwrap(); + let o = Orchestrator::new( + t, + baseref(), + ApplyTarget::Prefix { prefix: d.path().to_path_buf() }, + CompileMode::Skip, + ); + let p = o.run("compile x").unwrap(); + assert_eq!(p.skipped_source_patches, 1); + assert_eq!(p.checks[0].level, CheckLevel::Warn); + } + + #[test] + fn orchestrator_translates_unknown_intent_errors() { + let t = MockTranslator::empty(); + let d = tempfile::tempdir().unwrap(); + let o = Orchestrator::new( + t, + baseref(), + ApplyTarget::Prefix { prefix: d.path().to_path_buf() }, + CompileMode::Skip, + ); + let err = o.run("haz lo que tú quieras").unwrap_err().to_string(); + assert!(err.contains("no reconocida"), "{err}"); + } +} diff --git a/crates/hammer-agent/src/translator.rs b/crates/hammer-agent/src/translator.rs new file mode 100644 index 00000000..f334075c --- /dev/null +++ b/crates/hammer-agent/src/translator.rs @@ -0,0 +1,278 @@ +//! Traductor de intención (NL) → `.swm`. Ver `docs/08-ai-integration.md` §5. +//! +//! Contrato del trait: dado un texto humano + un `SystemContext` (lo que el orquestador +//! pudo recabar vía QUERY: pins, herramientas presentes, distro_version, etc.), producir un +//! `Swm` válido. +//! +//! Phase 6 entrega un [`MockTranslator`] que mapea intentos exactos contra un catálogo +//! YAML de SWMs pre-armados. Eso permite probar el bucle plan→build→try→verify→propose en +//! tests y en el host del autor sin LLM. La integración con un modelo real (Claude API, +//! local llama, etc.) se conecta detrás de este mismo trait — el bucle no cambia. + +use std::collections::HashMap; +use std::path::Path; + +use hammer_core::{BaseRef, Swm}; + +#[derive(Debug, thiserror::Error)] +pub enum TranslateError { + #[error("intent no reconocida en el catálogo: '{0}'")] + UnknownIntent(String), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("catálogo inválido: {0}")] + Catalog(String), + #[error("modelo: {0}")] + Model(String), +} + +/// Lo que el orquestador le pasa al traductor para contextualizar la intención. Un LLM +/// usaría esto para decidir, p. ej., "¿el sistema ya tiene grep 3.12? entonces no necesito +/// recompilar; sólo edito la config". Para el `MockTranslator` es decorativo. +#[derive(Debug, Clone)] +pub struct SystemContext { + pub base: BaseRef, + /// Información libre que el orquestador haya recolectado (rutas y metadatos de archivos + /// referenciados por el intent, salida de QUERY, etc.). Estructura JSON arbitraria. + pub extras: serde_json::Value, +} + +pub trait IntentTranslator { + fn translate(&self, intent: &str, ctx: &SystemContext) -> Result; +} + +/// Catálogo intento→.swm leído de un YAML: +/// +/// ```yaml +/// version: 1 +/// intents: +/// - intent: "set static IP 192.168.1.100" +/// swm_path: "swms/static-ip.swm" # ruta relativa al directorio del catálogo +/// # OR +/// swm_inline: { swm_version: 1, ... } +/// ``` +/// +/// El propósito es tener un "test cassette": el agente humano captura una intención que la +/// IA real ya resolvió, congela el `.swm` resultante, y a partir de ahí los CI/tests del +/// bucle agéntico son reproducibles. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct IntentCatalog { + #[serde(default = "default_version")] + pub version: u32, + pub intents: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct CatalogEntry { + pub intent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub swm_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub swm_inline: Option, +} + +fn default_version() -> u32 { + 1 +} + +impl IntentCatalog { + pub fn load_from_path(path: &Path) -> Result { + let text = std::fs::read_to_string(path)?; + let cat: IntentCatalog = serde_yaml::from_str(&text) + .map_err(|e| TranslateError::Catalog(e.to_string()))?; + if cat.version != 1 { + return Err(TranslateError::Catalog(format!( + "version del catálogo no soportada: {} (esperada 1)", + cat.version + ))); + } + Ok(cat) + } + + /// Resuelve un `intent` exacto contra el catálogo. Las entradas `swm_path` se resuelven + /// relativas al `base_dir` (típicamente el directorio del catálogo). + pub fn resolve(&self, intent: &str, base_dir: &Path) -> Result { + let entry = self + .intents + .iter() + .find(|e| e.intent == intent) + .ok_or_else(|| TranslateError::UnknownIntent(intent.into()))?; + if let Some(s) = &entry.swm_inline { + return Ok(s.clone()); + } + if let Some(p) = &entry.swm_path { + let full = base_dir.join(p); + let text = std::fs::read_to_string(&full)?; + return Swm::from_yaml(&text) + .map_err(|e| TranslateError::Catalog(format!("swm {}: {e}", full.display()))); + } + Err(TranslateError::Catalog(format!( + "entry '{intent}' no declara swm_path ni swm_inline" + ))) + } +} + +/// Traductor offline: matchea la intención exacta contra un mapa precargado. +#[derive(Debug, Default)] +pub struct MockTranslator { + map: HashMap, +} + +impl MockTranslator { + pub fn empty() -> Self { + Self { map: HashMap::new() } + } + + pub fn from_catalog(cat: &IntentCatalog, base_dir: &Path) -> Result { + let mut map = HashMap::new(); + for entry in &cat.intents { + let swm = match (&entry.swm_inline, &entry.swm_path) { + (Some(s), None) => s.clone(), + (None, Some(p)) => { + let full = base_dir.join(p); + let text = std::fs::read_to_string(&full)?; + Swm::from_yaml(&text).map_err(|e| { + TranslateError::Catalog(format!("swm {}: {e}", full.display())) + })? + } + _ => { + return Err(TranslateError::Catalog(format!( + "entry '{}' debe declarar exactamente uno de swm_inline/swm_path", + entry.intent + ))); + } + }; + map.insert(entry.intent.clone(), swm); + } + Ok(Self { map }) + } + + pub fn with_intent(mut self, intent: impl Into, swm: Swm) -> Self { + self.map.insert(intent.into(), swm); + self + } +} + +impl IntentTranslator for MockTranslator { + fn translate(&self, intent: &str, _ctx: &SystemContext) -> Result { + self.map + .get(intent) + .cloned() + .ok_or_else(|| TranslateError::UnknownIntent(intent.into())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hammer_core::swm::{Base, Mutation}; + use std::collections::BTreeMap; + + fn empty_ctx() -> SystemContext { + SystemContext { + base: BaseRef { + distro_version: "2026-06-06".into(), + pins: BTreeMap::new(), + }, + extras: serde_json::Value::Null, + } + } + + fn swm_demo() -> Swm { + Swm { + swm_version: 1, + base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() }, + mutations: vec![Mutation::ConfigEdit { + file: "/etc/network.conf".into(), + inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(), + }], + signature: None, + } + } + + #[test] + fn mock_translator_returns_known_swm() { + let t = MockTranslator::empty().with_intent("set static ip", swm_demo()); + let s = t.translate("set static ip", &empty_ctx()).unwrap(); + assert_eq!(s.mutations.len(), 1); + } + + #[test] + fn mock_translator_rejects_unknown() { + let t = MockTranslator::empty(); + let err = t.translate("paint the wall green", &empty_ctx()).unwrap_err(); + assert!(matches!(err, TranslateError::UnknownIntent(_))); + } + + #[test] + fn catalog_load_inline() { + let d = tempfile::tempdir().unwrap(); + let cat_path = d.path().join("catalog.yaml"); + std::fs::write( + &cat_path, + r#" +version: 1 +intents: + - intent: "set static ip" + swm_inline: + swm_version: 1 + base: + distro_version: "2026-06-06" + mutations: + - type: config_edit + file: "/etc/network.conf" + inline_diff: "- DHCP=yes\n+ STATIC=1\n" +"#, + ) + .unwrap(); + let cat = IntentCatalog::load_from_path(&cat_path).unwrap(); + let t = MockTranslator::from_catalog(&cat, d.path()).unwrap(); + let s = t.translate("set static ip", &empty_ctx()).unwrap(); + assert_eq!(s.mutations.len(), 1); + } + + #[test] + fn catalog_load_from_swm_path() { + let d = tempfile::tempdir().unwrap(); + let swm_path = d.path().join("net.swm"); + std::fs::write(&swm_path, swm_demo().to_yaml().unwrap()).unwrap(); + let cat_path = d.path().join("catalog.yaml"); + std::fs::write( + &cat_path, + r#" +version: 1 +intents: + - intent: "set static ip" + swm_path: "net.swm" +"#, + ) + .unwrap(); + let cat = IntentCatalog::load_from_path(&cat_path).unwrap(); + let t = MockTranslator::from_catalog(&cat, d.path()).unwrap(); + let s = t.translate("set static ip", &empty_ctx()).unwrap(); + assert_eq!(s.mutations.len(), 1); + } + + #[test] + fn catalog_rejects_unsupported_version() { + let d = tempfile::tempdir().unwrap(); + let cat_path = d.path().join("catalog.yaml"); + std::fs::write(&cat_path, "version: 9\nintents: []\n").unwrap(); + let err = IntentCatalog::load_from_path(&cat_path).unwrap_err(); + assert!(matches!(err, TranslateError::Catalog(_))); + } + + #[test] + fn catalog_rejects_entry_without_swm() { + let d = tempfile::tempdir().unwrap(); + let cat_path = d.path().join("catalog.yaml"); + std::fs::write( + &cat_path, + "version: 1\nintents:\n - intent: empty\n", + ) + .unwrap(); + let cat = IntentCatalog::load_from_path(&cat_path).unwrap(); + let err = MockTranslator::from_catalog(&cat, d.path()).unwrap_err(); + assert!(matches!(err, TranslateError::Catalog(_))); + } +} diff --git a/crates/hammer-agent/tests/ai_loop_e2e.rs b/crates/hammer-agent/tests/ai_loop_e2e.rs new file mode 100644 index 00000000..0a39135c --- /dev/null +++ b/crates/hammer-agent/tests/ai_loop_e2e.rs @@ -0,0 +1,150 @@ +//! E2E del bucle agéntico (Fase 6): +//! +//! Camino directo (sin bus): un `MockTranslator` resuelve un intent conocido y el +//! `Orchestrator` aplica las mutaciones bajo un prefix temporal. La prueba final pinta el +//! cambio en disco y verifica que es el esperado. +//! +//! NO ejercita el bus aquí — el camino bus + cliente se cubre en +//! `crates/hammer-agent/tests/client_e2e.rs` cuando lo construyamos sobre los crates +//! `hammerd` (Fase 5). Phase 6 ya prueba bus + cliente en bus_e2e.rs del propio hammerd. + +use std::collections::BTreeMap; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use hammer_agent::{ + orchestrator::{ApplyTarget, CompileMode}, + MockTranslator, Orchestrator, +}; +use hammer_core::swm::{Base, Mutation, Swm}; +use hammer_core::{ArtifactHash, BaseRef}; + +fn baseref() -> BaseRef { + let mut pins = BTreeMap::new(); + pins.insert("musl".into(), "deadbeef".into()); + BaseRef { distro_version: "2026-06-06".into(), pins } +} + +fn swm_two_mutations() -> Swm { + let payload = b"datos finales\n"; + let payload_hash = ArtifactHash::of_inputs(&[payload.as_slice()]).as_str().to_string(); + let payload_b64 = STANDARD.encode(payload); + Swm { + swm_version: 1, + base: Base { + distro_version: "2026-06-06".into(), + pins: { + let mut m = BTreeMap::new(); + m.insert("musl".into(), "deadbeef".into()); + m + }, + }, + mutations: vec![ + Mutation::ConfigEdit { + file: "/etc/network.conf".into(), + inline_diff: "- DHCP=yes\n+ IP=192.168.1.100\n".into(), + }, + Mutation::FileDrop { + path: "/var/lib/agent/notes.txt".into(), + content_hash: payload_hash, + content_b64: Some(payload_b64), + content_url: None, + }, + ], + signature: None, + } +} + +#[test] +fn intent_to_overlay_proposal_via_mock_translator() { + let d = tempfile::tempdir().unwrap(); + let prefix = d.path().join("prefix"); + std::fs::create_dir_all(prefix.join("etc")).unwrap(); + std::fs::write( + prefix.join("etc/network.conf"), + "USE=eth0\nDHCP=yes\nMTU=1500\n", + ) + .unwrap(); + + let translator = MockTranslator::empty() + .with_intent("set static ip and drop notes", swm_two_mutations()); + + let orch = Orchestrator::new( + translator, + baseref(), + ApplyTarget::Prefix { prefix: prefix.clone() }, + CompileMode::Skip, + ); + + let proposal = orch.run("set static ip and drop notes").expect("proposal"); + + assert_eq!(proposal.applied.config_edit, 1); + assert_eq!(proposal.applied.file_drop, 1); + assert_eq!(proposal.applied.source_patch, 0); + assert!(proposal.overlay_id.is_none(), "modo Prefix no abre overlay"); + assert_eq!(proposal.checks.len(), 2); + assert!( + proposal + .checks + .iter() + .all(|c| matches!(c.level, hammer_agent::orchestrator::CheckLevel::Pass)), + "todas las checks deben pasar" + ); + + // Verificación post-condición: los archivos en disco coinciden con lo esperado. + let net = std::fs::read_to_string(prefix.join("etc/network.conf")).unwrap(); + assert_eq!(net, "USE=eth0\nIP=192.168.1.100\nMTU=1500\n"); + let notes = std::fs::read(prefix.join("var/lib/agent/notes.txt")).unwrap(); + assert_eq!(notes, b"datos finales\n"); +} + +#[test] +fn unknown_intent_surfaces_translator_error() { + let d = tempfile::tempdir().unwrap(); + let orch = Orchestrator::new( + MockTranslator::empty(), + baseref(), + ApplyTarget::Prefix { prefix: d.path().to_path_buf() }, + CompileMode::Skip, + ); + let err = orch + .run("intencion sin entrada en el catalogo") + .unwrap_err() + .to_string(); + assert!(err.contains("no reconocida"), "{err}"); +} + +#[test] +fn proposal_is_serializable_for_humans() { + // Sanidad: el Proposal serializa a JSON. La CLI o un cliente externo lo puede mostrar. + let d = tempfile::tempdir().unwrap(); + let prefix = d.path().join("prefix"); + std::fs::create_dir_all(prefix.join("etc")).unwrap(); + std::fs::write(prefix.join("etc/network.conf"), "DHCP=yes\n").unwrap(); + let swm = Swm { + swm_version: 1, + base: Base { + distro_version: "2026-06-06".into(), + pins: { + let mut m = BTreeMap::new(); + m.insert("musl".into(), "deadbeef".into()); + m + }, + }, + mutations: vec![Mutation::ConfigEdit { + file: "/etc/network.conf".into(), + inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(), + }], + signature: None, + }; + let translator = MockTranslator::empty().with_intent("x", swm); + let orch = Orchestrator::new( + translator, + baseref(), + ApplyTarget::Prefix { prefix }, + CompileMode::Skip, + ); + let proposal = orch.run("x").unwrap(); + let json = serde_json::to_string(&proposal).unwrap(); + assert!(json.contains("\"applied\"")); + assert!(json.contains("\"checks\"")); +} diff --git a/crates/hammer-agent/tests/client_against_stub_bus.rs b/crates/hammer-agent/tests/client_against_stub_bus.rs new file mode 100644 index 00000000..000580e4 --- /dev/null +++ b/crates/hammer-agent/tests/client_against_stub_bus.rs @@ -0,0 +1,163 @@ +//! E2E del cliente del bus (Fase 6) contra un servidor stub. +//! +//! No depende de `hammerd` ni del lab: un mini-servidor JSON-líneas que conoce el +//! handshake y responde a `Compile` con `BuildReady` sintético. Demuestra que el cliente +//! síncrono multiplexa correctamente respuestas (BuildReady) y eventos asíncronos (Modified). + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION}; + +fn start_stub_bus(sock: PathBuf) -> std::sync::mpsc::Sender { + let (tx_async, rx_async) = std::sync::mpsc::channel::(); + let _ = std::fs::remove_file(&sock); + let listener = UnixListener::bind(&sock).expect("bind stub"); + thread::Builder::new() + .name("stub-bus".into()) + .spawn(move || { + // Sólo aceptamos una conexión: el Receiver no es Clone, se mueve al handler. + if let Some(Ok(stream)) = listener.incoming().next() { + thread::spawn(move || stub_serve(stream, rx_async)); + } + }) + .unwrap(); + tx_async +} + +fn stub_serve(stream: UnixStream, rx_async: std::sync::mpsc::Receiver) { + let reader_stream = stream.try_clone().unwrap(); + let mut writer = stream; + let writer_clone = writer.try_clone().unwrap(); + + // Hilo que reenvía los eventos asíncronos al cliente. Mover el writer impide que + // se mezclen los bytes con las respuestas (cada Sender los serializa atómicamente). + let async_writer = std::sync::Arc::new(std::sync::Mutex::new(writer_clone)); + let aw_for_async = async_writer.clone(); + thread::spawn(move || { + for ev in rx_async { + let line = serde_json::to_string(&ev).unwrap(); + let mut g = aw_for_async.lock().unwrap(); + let _ = g.write_all(line.as_bytes()); + let _ = g.write_all(b"\n"); + let _ = g.flush(); + } + }); + + let mut reader = BufReader::new(reader_stream); + let mut first = String::new(); + reader.read_line(&mut first).unwrap(); + let cmd: Command = serde_json::from_str(first.trim()).unwrap(); + let Command::Hello { ver: _, client: _ } = cmd else { + return; + }; + // welcome + let w = Event::Welcome { + ver: PROTOCOL_VERSION, + caps: vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init], + peer: Peer { uid: 1000, gid: 1000, pid: 1 }, + }; + let mut g = async_writer.lock().unwrap(); + g.write_all(serde_json::to_string(&w).unwrap().as_bytes()).unwrap(); + g.write_all(b"\n").unwrap(); + g.flush().unwrap(); + drop(g); + + // Loop de comandos. + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => {} + Err(_) => break, + } + if line.trim().is_empty() { + continue; + } + let cmd: Command = match serde_json::from_str(line.trim()) { + Ok(c) => c, + Err(_) => continue, + }; + let response = match cmd { + Command::Compile { recipe } => Event::BuildReady { + recipe: recipe.name.clone(), + artifact: format!("b3:stub-{}", recipe.commit), + }, + Command::Query { what, .. } => Event::QueryResult { + what, + value: serde_json::json!({"stub": true}), + }, + Command::Init { cmd } => Event::InitAck { cmd }, + Command::Inject { artifact, target, .. } => Event::Injected { + artifact, + target, + files: 1, + }, + Command::Hello { .. } => Event::Error { + code: "duplicate_hello".into(), + msg: "ya hicimos hello".into(), + }, + }; + let mut g = async_writer.lock().unwrap(); + g.write_all(serde_json::to_string(&response).unwrap().as_bytes()) + .unwrap(); + g.write_all(b"\n").unwrap(); + g.flush().unwrap(); + drop(g); + + // Cierra solo si el cliente cierra; aquí seguimos. + if !writer.flush().is_ok() { + break; + } + } +} + +#[test] +fn client_handshake_compile_and_async_modified() { + let d = tempfile::tempdir().unwrap(); + let sock = d.path().join("stub.sock"); + let async_tx = start_stub_bus(sock.clone()); + + // Pequeño wait para que el listener bindee. + for _ in 0..50 { + if sock.exists() { break } + thread::sleep(Duration::from_millis(10)); + } + + let mut client = hammer_agent::AgentClient::connect(&sock).expect("connect"); + assert_eq!(client.welcome.ver, PROTOCOL_VERSION); + assert!(client.welcome.caps.contains(&Cap::Compile)); + + let recipe = RecipeInline { + name: "grep".into(), + repo: "git://example/grep.git".into(), + commit: "abc123".into(), + patch: None, + compiler: "zig-cc".into(), + target: "x86_64-linux-musl".into(), + link: "static".into(), + flags: vec![], + }; + let artifact = client + .compile(recipe, Duration::from_secs(2)) + .expect("BuildReady"); + assert_eq!(artifact, "b3:stub-abc123"); + + // El stub también puede empujar eventos asíncronos. Inyectamos un Modified y vemos + // que el cliente lo entrega por drain_async()/next_async(). + async_tx + .send(Event::Modified { + path: "/bin/grep".into(), + op: "replace".into(), + ts: "2026-06-09T00:00:00Z".into(), + }) + .unwrap(); + let ev = client.next_async(Duration::from_secs(2)).expect("async"); + match ev { + Event::Modified { path, .. } => assert_eq!(path, "/bin/grep"), + other => panic!("esperaba Modified, llegó {other:?}"), + } +} diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml index ecf9b44b..e60204ab 100644 --- a/crates/hammer-cli/Cargo.toml +++ b/crates/hammer-cli/Cargo.toml @@ -16,6 +16,7 @@ hammer-core.workspace = true hammer-build.workspace = true hammer-overlay.workspace = true hammer-journal.workspace = true +hammer-agent.workspace = true serde_json.workspace = true base64.workspace = true anyhow.workspace = true diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index beec6f63..890bc4bc 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -133,6 +133,30 @@ enum Cmd { #[arg(long)] since: Option, }, + /// [Fase 6] Ejecuta el bucle agéntico: traduce una intención NL a un .swm vía catálogo + /// mock y la aplica a un overlay (o prefix) sin promover al FHS. Imprime el Proposal. + Ai { + /// La intención en lenguaje natural (string exacta, indexada en el catálogo). + intent: String, + /// Catálogo YAML de intent → swm. Cuando exista un traductor LLM real, este flag + /// será opcional y se documentará el path por defecto. + #[arg(long)] + catalog: PathBuf, + /// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para + /// dev/CI sin root ni overlayfs. + #[arg(long)] + prefix: Option, + /// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella. + #[arg(long)] + base_ref: Option, + /// Socket de hammerd. Si está + el .swm trae source_patch, el orquestador llama + /// Compile por el bus. Si no, source_patch se omite con warning. + #[arg(long)] + bus: Option, + /// Raíz de overlays para `try`. Default coincide con `hammer try`. + #[arg(long)] + state_root: Option, + }, /// [Fase 5] Envía un comando al FIFO de control humano del init. /// Por defecto: /run/init.control. Equivalente a `echo "" > /run/init.control`, /// pero con un mensaje de error claro si el FIFO no existe. @@ -309,6 +333,17 @@ fn main() -> anyhow::Result<()> { Cmd::Export { base_ref, journal, since } => { run_export(base_ref.as_deref(), &journal, since.as_deref())?; } + Cmd::Ai { intent, catalog, prefix, base_ref, bus, state_root } => { + run_ai( + &intent, + &catalog, + prefix.as_deref(), + base_ref.as_deref(), + bus.as_deref(), + state_root.as_deref(), + &cli.store, + )?; + } Cmd::Ctl { line, fifo } => { run_ctl(&line, &fifo)?; } @@ -602,6 +637,114 @@ fn run_export( Ok(()) } +/// Ejecuta el bucle agéntico de Fase 6 vía `hammer-agent`. Acepta una intención NL + +/// catálogo YAML que mapea intentos exactos a `.swm`s pre-armados. La salida es un +/// `Proposal` legible en stdout + el siguiente paso recomendado al humano. +fn run_ai( + intent: &str, + catalog: &std::path::Path, + prefix: Option<&std::path::Path>, + base_ref: Option<&std::path::Path>, + bus: Option<&std::path::Path>, + state_root: Option<&std::path::Path>, + store_root: &str, +) -> anyhow::Result<()> { + use hammer_agent::{ + orchestrator::{ApplyTarget, CompileMode}, + IntentCatalog, MockTranslator, Orchestrator, + }; + + let cat = IntentCatalog::load_from_path(catalog) + .map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?; + let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new(".")); + let translator = MockTranslator::from_catalog(&cat, base_dir) + .map_err(|e| anyhow::anyhow!("catalog: {e}"))?; + + // BaseRef: si se da, leerlo; si no, fabricar uno permisivo desde la base del catálogo + // (la usaremos sólo para verify_base, que tolera pins extra en local). + let base = match load_local_base(base_ref)? { + Some(b) => b, + None => { + // Tomamos la base del swm del intent (si existe) como referencia local. + let ctx_swm = cat + .intents + .iter() + .find(|e| e.intent == intent) + .ok_or_else(|| anyhow::anyhow!("intent '{intent}' no está en el catálogo"))? + .swm_inline + .clone() + .or_else(|| { + cat.intents + .iter() + .find(|e| e.intent == intent) + .and_then(|e| e.swm_path.as_ref()) + .and_then(|p| std::fs::read_to_string(base_dir.join(p)).ok()) + .and_then(|t| hammer_core::Swm::from_yaml(&t).ok()) + }); + match ctx_swm { + Some(s) => hammer_core::BaseRef::from(&s.base), + None => anyhow::bail!("no pude inferir BaseRef sin --base-ref"), + } + } + }; + + let apply = match prefix { + Some(p) => ApplyTarget::Prefix { prefix: p.to_path_buf() }, + None => ApplyTarget::Overlay { + state_root: state_root + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)), + }, + }; + + let compile = match bus { + Some(s) => CompileMode::ViaBus { + sock: s.to_path_buf(), + store: PathBuf::from(store_root), + timeout: std::time::Duration::from_secs(600), + }, + None => CompileMode::Skip, + }; + + let orch = Orchestrator::new(translator, base, apply, compile); + let proposal = orch + .run(intent) + .map_err(|e| anyhow::anyhow!("orchestrator: {e}"))?; + + println!("--- proposal ---"); + println!("intent: {}", proposal.intent); + println!( + "applied: source_patch={} config_edit={} init_rule={} file_drop={}", + proposal.applied.source_patch, + proposal.applied.config_edit, + proposal.applied.init_rule, + proposal.applied.file_drop, + ); + if proposal.skipped_source_patches > 0 { + println!( + "skipped_source_patches: {} (sin --bus)", + proposal.skipped_source_patches + ); + } + println!("checks:"); + for c in &proposal.checks { + println!(" [{:?}] {}", c.level, c.msg); + } + match (&proposal.overlay_id, &proposal.prefix) { + (Some(id), _) => { + println!("\noverlay {id} listo."); + println!(" Revisa el cambio en caliente y luego:"); + println!(" hammer commit {id} # promueve + diario"); + println!(" hammer discard {id} # descarta"); + } + (None, Some(p)) => { + println!("\nprefix {} actualizado (modo test, sin overlay).", p.display()); + } + _ => {} + } + Ok(()) +} + /// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería /// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un /// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito. diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index 46f1cca1..a029a97a 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod apply; pub mod hash; +pub mod proto; pub mod recipe; pub mod store; pub mod swm; diff --git a/crates/hammerd/src/proto.rs b/crates/hammer-core/src/proto.rs similarity index 100% rename from crates/hammerd/src/proto.rs rename to crates/hammer-core/src/proto.rs diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs index 35f39edc..1c23f5af 100644 --- a/crates/hammerd/src/bus.rs +++ b/crates/hammerd/src/bus.rs @@ -25,7 +25,7 @@ use std::thread; use nix::sys::socket::{getsockopt, sockopt::PeerCredentials}; use crate::events::EventBus; -use crate::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION}; +use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION}; /// Política de capacidades: dado el `Peer` (ya autenticado por SO_PEERCRED), devuelve qué /// caps tiene esa conexión. Por defecto: misma UID que el daemon ⇒ todo menos `inject-real`; diff --git a/crates/hammerd/src/events.rs b/crates/hammerd/src/events.rs index 140880c4..5da2365c 100644 --- a/crates/hammerd/src/events.rs +++ b/crates/hammerd/src/events.rs @@ -12,7 +12,7 @@ use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::{Arc, Mutex}; -use crate::proto::Event; +use hammer_core::proto::Event; #[derive(Clone)] pub struct EventBus { @@ -58,7 +58,7 @@ impl EventBus { #[cfg(test)] mod tests { use super::*; - use crate::proto::Event; + use hammer_core::proto::Event; #[test] fn publish_reaches_all_subs() { diff --git a/crates/hammerd/src/main.rs b/crates/hammerd/src/main.rs index d8c0e3ae..ec957e5f 100644 --- a/crates/hammerd/src/main.rs +++ b/crates/hammerd/src/main.rs @@ -15,7 +15,6 @@ use clap::Parser; mod bus; mod control; mod events; -mod proto; mod watcher; #[derive(Parser)] diff --git a/crates/hammerd/src/watcher.rs b/crates/hammerd/src/watcher.rs index db7d02e5..1e100726 100644 --- a/crates/hammerd/src/watcher.rs +++ b/crates/hammerd/src/watcher.rs @@ -162,7 +162,7 @@ impl Watcher { self.journal.record(&me)?; recorded += 1; if let Some(bus) = &self.events { - bus.publish(&crate::proto::Event::Modified { + bus.publish(&hammer_core::proto::Event::Modified { path: me.path.display().to_string(), op: me.op.as_str().to_string(), ts: me.ts.clone(), diff --git a/crates/hammerd/tests/bus_e2e.rs b/crates/hammerd/tests/bus_e2e.rs index e5945657..4ead0c24 100644 --- a/crates/hammerd/tests/bus_e2e.rs +++ b/crates/hammerd/tests/bus_e2e.rs @@ -7,9 +7,8 @@ //! grep gated en `HAMMER_NETWORK_TESTS`. Lo que sí verificamos es que un `compile` SIN la //! capacidad devuelve `Error{code:"no_cap"}`, lo que prueba el dispatcher + el gate. -// El binario no expone una API pública; importamos el módulo desde `path = ...`. -#[path = "../src/proto.rs"] -mod proto; +// El binario no expone una API pública; importamos sus módulos privados desde +// `path = ...`. `proto` se movió a hammer-core en Fase 6. #[path = "../src/events.rs"] mod events; #[path = "../src/control.rs"] @@ -24,7 +23,7 @@ use std::sync::Arc; use std::thread; use std::time::Duration; -use proto::{Cap, Command, Event, Peer, RecipeInline}; +use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline}; /// Helper: lanza un bus en background y devuelve la ruta del socket. Limpia al final del /// test gracias al `tempfile::TempDir` que se le pasa al caller. diff --git a/docs/08-ai-integration.md b/docs/08-ai-integration.md index 76525b76..9c16b680 100644 --- a/docs/08-ai-integration.md +++ b/docs/08-ai-integration.md @@ -77,14 +77,22 @@ expuestas y de baja entropía**, que es justo lo que un agente necesita. ## 5. De la intención al `.swm`: el rol del modelo -El traductor intención→`.swm` es un LLM. Detalles de modelo/API (Claude, herramientas, prompts) -se documentarán aparte cuando se implemente la Fase 6; aquí sólo fijamos el contrato: +El traductor intención→`.swm` se conecta detrás del trait `IntentTranslator` del crate +`hammer-agent`. Contrato: -- **Entrada:** intención en NL + contexto del sistema (qué herramientas hay, qué pins, estado - de servicios — todo consultable por `QUERY`). -- **Salida:** un `.swm` válido ([SDD 06](06-swm-format.md)) + un plan de verificación. -- **El modelo no ejecuta nada directamente:** emite comandos por el bus, que `hammerd` valida - contra las capacidades concedidas. +- **Entrada:** intención en NL + `SystemContext` (lo que el orquestador recolecta por + `QUERY`: `BaseRef` local, extras JSON). +- **Salida:** un `Swm` válido ([SDD 06](06-swm-format.md)). + +Fase 6 entrega un `MockTranslator` que resuelve intentos contra un `IntentCatalog` YAML +(intent exacto → `.swm` inline o `swm_path` relativo). Eso permite probar el bucle sin LLM: +el humano que descubrió la intención con un modelo real congela el resultado y los CI lo +reproducen byte-a-byte. El traductor LLM (Claude API u otro) implementa el mismo trait y se +enchufa sin tocar el `Orchestrator`. + +**El modelo nunca ejecuta nada directamente:** emite comandos por el bus, que `hammerd` +valida contra las capacidades concedidas, y el `Orchestrator` traduce las mutaciones a +`hammer-core::apply` o a `Compile` por el bus. ## 6. Lenguaje de consulta/transformación (visión, Fase posterior) @@ -101,13 +109,34 @@ esté probado. ## 7. Interfaz +El crate `hammer-agent` (Fase 6) expone: + ```rust -// cliente del bus (puede vivir en un crate aparte hammer-agent en Fase 6) -pub trait AgentClient { - fn hello(&mut self) -> Result; - fn compile(&mut self, recipe: &Recipe) -> Result; - fn inject(&mut self, h: &ArtifactHash, target: &Path, overlay: OverlayId) -> Result<()>; - fn query(&mut self, q: Query) -> Result; - fn events(&mut self) -> impl Iterator; +// hammer_agent::client +pub struct AgentClient { pub welcome: Welcome, /* … */ } +impl AgentClient { + pub fn connect(sock: &Path) -> Result; + pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result; // artifact + pub fn inject(&mut self, artifact: &str, target: &str, overlay: Option<&str>, t: Duration) -> Result; + pub fn query_file(&mut self, path: &str, timeout: Duration) -> Result; + pub fn init(&mut self, cmd: &str, timeout: Duration) -> Result<()>; + pub fn drain_async(&self) -> Vec; + pub fn next_async(&self, timeout: Duration) -> Result; +} + +// hammer_agent::translator +pub trait IntentTranslator { fn translate(&self, intent: &str, ctx: &SystemContext) -> Result; } +pub struct MockTranslator { /* HashMap */ } +pub struct IntentCatalog { pub intents: Vec } // YAML loader + +// hammer_agent::orchestrator +pub struct Orchestrator { /* … */ } +impl Orchestrator { + pub fn new(t: T, base: BaseRef, apply: ApplyTarget, compile: CompileMode) -> Self; + pub fn run(&self, intent: &str) -> Result; } ``` + +Los tipos del protocolo (`Command`/`Event`/`Cap`/`Peer`/`RecipeInline`) viven en +`hammer-core::proto` para ser compartidos por `hammerd`, `hammer-agent` y cualquier cliente +externo escrito en Rust. diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 02d0d77a..8a925467 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -99,10 +99,32 @@ pre-requisito de validación. gating `no_cap`, `Query`, `Modified` fan-out, `Init`→FIFO. Un `Compile` real reusa el camino de Fase 0/1 (gated en `HAMMER_NETWORK_TESTS`). -### Fase 6 — Integración de la IA -- [ ] Cliente de agente; traductor intención NL → `.swm`; bucle plan→build→try→verify→propose. +### Fase 6 — Integración de la IA ▶ *en progreso* +- [x] Crate `hammer-agent` con tres piezas: + - `AgentClient`: cliente síncrono del bus (handshake, `compile`/`inject`/`query`/`init` + bloqueantes, drenado de eventos asíncronos). + - `IntentTranslator` (trait) + `MockTranslator` cargado desde un `IntentCatalog` YAML + (intent → `.swm` pre-armado). La integración con un LLM real se enchufa detrás del + mismo trait sin tocar el bucle. + - `Orchestrator` con `run(intent) → Proposal`: plan → schema → base → try (overlay + o prefix) → apply (mutaciones puras + source_patch vía bus opcional) → verify → + propose. +- [x] CLI: `hammer ai --catalog F [--prefix DIR --base-ref F --bus SOCK]`. +- [x] Tipos del protocolo del bus movidos a `hammer-core::proto` para que `hammerd` y + `hammer-agent` los compartan. +- [x] Tests: + - 10 unit en `hammer-agent` (translator + catalog + orchestrator). + - 3 e2e del bucle agéntico (prefix tmp → archivos esperados en disco). + - 1 e2e del cliente contra un *stub* del bus (handshake + Compile→BuildReady + Modified + asíncrono). +- [ ] Traductor LLM real (Claude API u otro), opcional vía feature flag o crate aparte. +- [ ] Lenguaje de consulta del sistema (SDD 08 §6) para que la IA refiera servicios y + archivos sin rutas frágiles. +- [ ] Bucle de auto-reparación (cliente reacciona a `Crashed` con un nuevo plan). - **Hecho cuando:** una intención en lenguaje natural produce un cambio probado en overlay, - presentado para `commit` humano. + presentado para `commit` humano. ✅ Demostrado por `hammer ai` con `MockTranslator`: + intent → `.swm` → mutaciones aplicadas → `Proposal` con `overlay_id` + checks. El humano + decide `commit`/`discard`. El upgrade a LLM real reusa todo el bucle. ---