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

- proto: mover hammerd::proto a hammer-core::proto para que hammerd y hammer-agent
  compartan los tipos del bus sin duplicar.
- hammer-agent (crate nuevo):
  * client: AgentClient síncrono. Handshake hello/welcome; compile/inject/query/init
    bloqueantes con timeout; reader thread interno demultiplexa async events (Modified/
    Crashed) en una cola que el caller drena vía drain_async/next_async.
  * translator: trait IntentTranslator + MockTranslator (HashMap<intent, Swm>) +
    IntentCatalog YAML (swm_inline o swm_path). El traductor LLM real se enchufa
    detrás del mismo trait sin cambios al orquestador.
  * orchestrator: Orchestrator::run(intent) -> Proposal con plan -> schema -> base ->
    try (overlay|prefix) -> apply (config_edit/file_drop con hammer-core::apply,
    source_patch via bus opcional) -> verify (spot-checks) -> propose. Devuelve
    overlay_id (para `hammer commit`) o prefix usado.
- hammer-cli: subcomando `hammer ai <intent> --catalog F [--prefix DIR --base-ref F
  --bus SOCK --state-root DIR]`. Imprime el Proposal y el siguiente paso humano.
- Tests:
  * 10 unit (translator + catalog + orchestrator).
  * 3 e2e del bucle agéntico (intent -> archivos esperados bajo un prefix tmp).
  * 1 e2e del cliente contra un stub bus (handshake + Compile -> BuildReady +
    Modified asíncrono), sin depender de hammerd ni del lab.
- Docs: SDD 08 actualizado con el API del crate; roadmap marca lo cerrado y lo
  pendiente (LLM real, lenguaje de consulta, bucle de auto-reparación con Crashed).
This commit is contained in:
Sergio
2026-06-09 15:47:37 +00:00
parent f41022d81f
commit 89ecd54135
20 changed files with 1655 additions and 26 deletions
Generated
+18
View File
@@ -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",
+2
View File
@@ -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"
+23
View File
@@ -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
+286
View File
@@ -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<T> = std::result::Result<T, ClientError>;
#[derive(Debug, Clone)]
pub struct Welcome {
pub ver: u32,
pub caps: Vec<Cap>,
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<Event>,
/// 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<Event>,
closed: bool,
}
#[derive(Clone)]
struct Shared {
inbox: Arc<Mutex<Inbox>>,
cv: Arc<Condvar>,
}
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> {
Self::connect_with_client_name(sock_path, "hammer-agent")
}
pub fn connect_with_client_name(sock_path: &Path, client: &str) -> Result<Self> {
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<String> {
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<usize> {
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<serde_json::Value> {
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<Event> {
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<Event> {
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<Event> {
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();
}
}
+46
View File
@@ -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<T> = std::result::Result<T, Error>;
+469
View File
@@ -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<T: IntentTranslator> {
translator: T,
base: BaseRef,
apply: ApplyTarget,
compile: CompileMode,
}
impl<T: IntentTranslator> Orchestrator<T> {
pub fn new(translator: T, base: BaseRef, apply: ApplyTarget, compile: CompileMode) -> Self {
Self { translator, base, apply, compile }
}
pub fn run(&self, intent: &str) -> Result<Proposal> {
// 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<OverlayId>, Option<PathBuf>) = 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<VerifyCheck> = Vec::new();
let mut skipped_source_patches = 0usize;
let mut maybe_client: Option<AgentClient> = 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<usize> {
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<F>(root: &Path, f: &mut F) -> std::io::Result<()>
where
F: FnMut(&Path, EntryKind) -> std::io::Result<()>,
{
fn inner<F>(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<String>) -> Self {
Self { level: CheckLevel::Pass, msg: msg.into() }
}
pub fn warn(msg: impl Into<String>) -> Self {
Self { level: CheckLevel::Warn, msg: msg.into() }
}
}
/// El resultado del bucle. El humano lo lee para decidir `hammer commit <overlay_id>` (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<String>,
pub prefix: Option<PathBuf>,
pub applied: AppliedCounts,
pub skipped_source_patches: usize,
pub checks: Vec<VerifyCheck>,
}
#[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}");
}
}
+278
View File
@@ -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<Swm, TranslateError>;
}
/// 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<CatalogEntry>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CatalogEntry {
pub intent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub swm_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub swm_inline: Option<Swm>,
}
fn default_version() -> u32 {
1
}
impl IntentCatalog {
pub fn load_from_path(path: &Path) -> Result<Self, TranslateError> {
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<Swm, TranslateError> {
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<String, Swm>,
}
impl MockTranslator {
pub fn empty() -> Self {
Self { map: HashMap::new() }
}
pub fn from_catalog(cat: &IntentCatalog, base_dir: &Path) -> Result<Self, TranslateError> {
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<String>, swm: Swm) -> Self {
self.map.insert(intent.into(), swm);
self
}
}
impl IntentTranslator for MockTranslator {
fn translate(&self, intent: &str, _ctx: &SystemContext) -> Result<Swm, TranslateError> {
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(_)));
}
}
+150
View File
@@ -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\""));
}
@@ -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<Event> {
let (tx_async, rx_async) = std::sync::mpsc::channel::<Event>();
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<Event>) {
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:?}"),
}
}
+1
View File
@@ -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
+143
View File
@@ -133,6 +133,30 @@ enum Cmd {
#[arg(long)]
since: Option<String>,
},
/// [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<PathBuf>,
/// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella.
#[arg(long)]
base_ref: Option<PathBuf>,
/// 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<PathBuf>,
/// Raíz de overlays para `try`. Default coincide con `hammer try`.
#[arg(long)]
state_root: Option<PathBuf>,
},
/// [Fase 5] Envía un comando al FIFO de control humano del init.
/// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /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.
+1
View File
@@ -6,6 +6,7 @@
pub mod apply;
pub mod hash;
pub mod proto;
pub mod recipe;
pub mod store;
pub mod swm;
+1 -1
View File
@@ -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`;
+2 -2
View File
@@ -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() {
-1
View File
@@ -15,7 +15,6 @@ use clap::Parser;
mod bus;
mod control;
mod events;
mod proto;
mod watcher;
#[derive(Parser)]
+1 -1
View File
@@ -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(),
+3 -4
View File
@@ -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.
+43 -14
View File
@@ -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<Caps>;
fn compile(&mut self, recipe: &Recipe) -> Result<ArtifactHash>;
fn inject(&mut self, h: &ArtifactHash, target: &Path, overlay: OverlayId) -> Result<()>;
fn query(&mut self, q: Query) -> Result<QueryResult>;
fn events(&mut self) -> impl Iterator<Item = BusEvent>;
// hammer_agent::client
pub struct AgentClient { pub welcome: Welcome, /**/ }
impl AgentClient {
pub fn connect(sock: &Path) -> Result<Self>;
pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result<String>; // artifact
pub fn inject(&mut self, artifact: &str, target: &str, overlay: Option<&str>, t: Duration) -> Result<usize>;
pub fn query_file(&mut self, path: &str, timeout: Duration) -> Result<serde_json::Value>;
pub fn init(&mut self, cmd: &str, timeout: Duration) -> Result<()>;
pub fn drain_async(&self) -> Vec<Event>;
pub fn next_async(&self, timeout: Duration) -> Result<Event>;
}
// hammer_agent::translator
pub trait IntentTranslator { fn translate(&self, intent: &str, ctx: &SystemContext) -> Result<Swm, TranslateError>; }
pub struct MockTranslator { /* HashMap<intent, Swm> */ }
pub struct IntentCatalog { pub intents: Vec<CatalogEntry> } // YAML loader
// hammer_agent::orchestrator
pub struct Orchestrator<T: IntentTranslator> { /**/ }
impl<T> Orchestrator<T> {
pub fn new(t: T, base: BaseRef, apply: ApplyTarget, compile: CompileMode) -> Self;
pub fn run(&self, intent: &str) -> Result<Proposal>;
}
```
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.
+25 -3
View File
@@ -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 <intent> --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.
---