diff --git a/Cargo.lock b/Cargo.lock index 22130192..b844b643 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -232,6 +232,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -245,7 +256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -266,6 +277,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -276,6 +296,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -304,6 +335,7 @@ dependencies = [ "tempfile", "thiserror", "tracing", + "ureq", ] [[package]] @@ -429,12 +461,115 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -483,6 +618,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.32" @@ -532,7 +673,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -547,12 +688,27 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -604,6 +760,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -614,7 +784,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -726,12 +931,24 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -743,6 +960,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -750,10 +978,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -785,6 +1013,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml" version = "0.8.23" @@ -911,6 +1149,47 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -929,6 +1208,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -981,12 +1266,39 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -996,6 +1308,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -1099,6 +1475,95 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/crates/hammer-agent/Cargo.toml b/crates/hammer-agent/Cargo.toml index 8af02c68..e05c9ec7 100644 --- a/crates/hammer-agent/Cargo.toml +++ b/crates/hammer-agent/Cargo.toml @@ -7,6 +7,12 @@ authors.workspace = true repository.workspace = true description = "Cliente del bus + bucle agéntico (plan/build/try/verify/propose) para la IA." +[features] +default = [] +# Traductor LLM real contra Claude API (anthropic-version 2023-06-01). +# Trae `ureq` con rustls; si no la habilitas, el crate sigue siendo dependency-free de red. +llm-claude = ["dep:ureq"] + [dependencies] hammer-core.workspace = true hammer-overlay.workspace = true @@ -16,6 +22,7 @@ serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true tracing.workspace = true +ureq = { version = "2", optional = true, default-features = false, features = ["tls", "json"] } [dev-dependencies] tempfile.workspace = true diff --git a/crates/hammer-agent/src/lib.rs b/crates/hammer-agent/src/lib.rs index f14612e3..5d34ff9f 100644 --- a/crates/hammer-agent/src/lib.rs +++ b/crates/hammer-agent/src/lib.rs @@ -20,10 +20,12 @@ pub mod client; pub mod orchestrator; pub mod translator; +pub mod translator_claude; pub use client::{AgentClient, ClientError, Welcome}; pub use orchestrator::{Orchestrator, Proposal, VerifyCheck}; pub use translator::{IntentCatalog, IntentTranslator, MockTranslator, SystemContext, TranslateError}; +pub use translator_claude::{ClaudeConfig, ClaudeTranslator, HttpClient}; #[derive(Debug, thiserror::Error)] pub enum Error { diff --git a/crates/hammer-agent/src/translator_claude.rs b/crates/hammer-agent/src/translator_claude.rs new file mode 100644 index 00000000..0f146d2f --- /dev/null +++ b/crates/hammer-agent/src/translator_claude.rs @@ -0,0 +1,473 @@ +//! Traductor LLM real contra la Claude API (anthropic-version `2023-06-01`). +//! +//! Implementa [`IntentTranslator`] exactamente como el `MockTranslator`, así que el bucle +//! del [`Orchestrator`](crate::Orchestrator) no cambia. Lo que ofrece de extra es que el +//! contenido de la intención NL llega a un modelo real (Claude Opus 4.8 por defecto) que +//! produce el `.swm` directamente — no se requiere catálogo pre-armado. +//! +//! Diseño: +//! +//! - **Síncrono** y mínimo: usamos `ureq` (gated detrás del feature `llm-claude`) porque +//! el cliente del bus también es síncrono. Tokio entraría sólo si el resto del crate lo +//! pidiera. +//! - **HTTP inyectable**: el trait [`HttpClient`] permite tests sin red contra una fake +//! que devuelve respuestas pre-armadas y verifica el cuerpo enviado. +//! - **Modelo por defecto**: `claude-opus-4-8`, con adaptive thinking activado y +//! `effort: "high"`. Cambiar el modelo es modificar el `ClaudeConfig`. +//! - **Schema enforcement**: el modelo recibe un system prompt que documenta el shape +//! exacto del `Swm` con ejemplos; pedimos respuesta JSON pura. El parser local +//! (`serde_json::from_str::`) actúa como gate adicional, y `verify_schema()` +//! se llama antes de devolver. Si el modelo divaga, el caller obtiene un +//! `TranslateError::Model` con el cuerpo bruto para debugging. +//! +//! El bucle agéntico (con o sin auto-reparación) reusa el mismo `Orchestrator`. La única +//! diferencia es la construcción del traductor: +//! +//! ```ignore +//! let cfg = ClaudeConfig::from_env()?; +//! let t = ClaudeTranslator::new(cfg); +//! let orch = Orchestrator::new(t, base, apply, compile); +//! orch.run("instala grep con regex Perl por defecto")?; +//! ``` + +use crate::translator::{IntentTranslator, SystemContext, TranslateError}; +use hammer_core::Swm; + +/// Inyección HTTP. La implementación por defecto ([`UreqHttp`]) usa `ureq` síncrono; +/// los tests pasan una fake. +pub trait HttpClient: Send + Sync { + fn post_json( + &self, + url: &str, + headers: &[(&str, &str)], + body: &str, + ) -> Result; +} + +#[cfg(feature = "llm-claude")] +pub struct UreqHttp; + +#[cfg(feature = "llm-claude")] +impl HttpClient for UreqHttp { + fn post_json( + &self, + url: &str, + headers: &[(&str, &str)], + body: &str, + ) -> Result { + let mut req = ureq::post(url); + for (k, v) in headers { + req = req.set(k, v); + } + let resp = req + .send_string(body) + .map_err(|e| TranslateError::Model(format!("http: {e}")))?; + resp.into_string() + .map_err(|e| TranslateError::Model(format!("body: {e}"))) + } +} + +#[derive(Debug, Clone)] +pub struct ClaudeConfig { + pub api_key: String, + pub model: String, + pub max_tokens: u32, + pub base_url: String, + /// Nivel de esfuerzo del modelo (`low|medium|high|max`). Default `high`. + pub effort: String, +} + +impl ClaudeConfig { + /// Construye desde env vars: `ANTHROPIC_API_KEY` (obligatoria), `HAMMER_LLM_MODEL`, + /// `HAMMER_LLM_BASE_URL`, `HAMMER_LLM_EFFORT`. + pub fn from_env() -> Result { + let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| { + TranslateError::Model( + "ANTHROPIC_API_KEY no está en el entorno; exporta tu key o usa MockTranslator" + .into(), + ) + })?; + Ok(Self { + api_key, + model: std::env::var("HAMMER_LLM_MODEL") + .unwrap_or_else(|_| "claude-opus-4-8".into()), + max_tokens: 8192, + base_url: std::env::var("HAMMER_LLM_BASE_URL") + .unwrap_or_else(|_| "https://api.anthropic.com".into()), + effort: std::env::var("HAMMER_LLM_EFFORT").unwrap_or_else(|_| "high".into()), + }) + } +} + +pub struct ClaudeTranslator { + cfg: ClaudeConfig, + http: Box, +} + +impl ClaudeTranslator { + /// Constructor por defecto: usa [`UreqHttp`] (sólo disponible con feature `llm-claude`). + #[cfg(feature = "llm-claude")] + pub fn new(cfg: ClaudeConfig) -> Self { + Self { cfg, http: Box::new(UreqHttp) } + } + + /// Constructor con HTTP inyectado. Útil para tests y para apuntar a un proxy/mock. + pub fn with_http(cfg: ClaudeConfig, http: Box) -> Self { + Self { cfg, http } + } +} + +const SYSTEM_PROMPT: &str = r#"Eres un traductor que convierte intenciones humanas en lenguaje natural a manifiestos `.swm` (Software Mutación) del sistema hammer. El receptor parseará tu respuesta con serde_json (Rust) y rechazará cualquier output que no sea JSON puro válido. + +# Shape del Swm (versión 1) + +```json +{ + "swm_version": 1, + "base": { + "distro_version": "", + "pins": {} + }, + "mutations": [ ... ] +} +``` + +# Variantes de mutación + +1. config_edit — edita un archivo de texto vía unified diff. + {"type":"config_edit","file":"/abs/path","inline_diff":"- old line\n+ new line\n"} + - `file` debe ser ruta absoluta. + - `inline_diff` usa el formato de diff unified clásico (líneas `-`, `+` y espacio). + +2. file_drop — coloca un archivo en el sistema. + {"type":"file_drop","path":"/abs/path","content_hash":"b3:","content_b64":""} + - `content_hash` es BLAKE3 del contenido descodificado, con el prefijo `b3:`. + - SOLO emite `file_drop` si puedes calcular el hash correcto. Si no, salta esta variante. + +3. source_patch — compila desde fuente y la inyecta. + {"type":"source_patch","repo":"git://...","commit":"","build":{"compiler":"zig-cc","target":"x86_64-linux-musl","link":"static","flags":[]},"target_bin":"/usr/bin/foo"} + - `target_bin` ruta absoluta del binario producido. + +4. init_rule — registra un servicio. + {"type":"init_rule","action":"start","service":"nginx","command":"/usr/sbin/nginx -g 'daemon off;'"} + +# Reglas + +- TODAS las rutas son absolutas (comienzan con `/`). +- Copia `distro_version` y `pins` desde el SystemContext que te pase el usuario. +- Si la intención es ambigua o requiere información que no tienes (p. ej. el hash de un binario que no puedes computar), prefiere `config_edit` o `source_patch` sobre `file_drop`. +- Responde EXCLUSIVAMENTE con el JSON del Swm. Sin texto antes, sin texto después, sin markdown, sin backticks. La primera línea debe ser `{` y la última `}`. +"#; + +impl IntentTranslator for ClaudeTranslator { + fn translate(&self, intent: &str, ctx: &SystemContext) -> Result { + let body = build_request_body(&self.cfg, intent, ctx)?; + let url = format!( + "{}/v1/messages", + self.cfg.base_url.trim_end_matches('/') + ); + let auth = format!("{}", self.cfg.api_key); + let headers: [(&str, &str); 3] = [ + ("x-api-key", auth.as_str()), + ("anthropic-version", "2023-06-01"), + ("content-type", "application/json"), + ]; + let raw = self.http.post_json(&url, &headers, &body)?; + parse_response(&raw) + } +} + +fn build_request_body( + cfg: &ClaudeConfig, + intent: &str, + ctx: &SystemContext, +) -> Result { + let ctx_json = serde_json::json!({ + "distro_version": ctx.base.distro_version, + "pins": ctx.base.pins, + "extras": ctx.extras, + }); + let user_msg = format!( + "SystemContext:\n{}\n\nIntención del humano:\n{}\n\nRecuerda: responde sólo con el JSON del Swm.", + serde_json::to_string_pretty(&ctx_json).unwrap_or_else(|_| "{}".into()), + intent, + ); + let body = serde_json::json!({ + "model": cfg.model, + "max_tokens": cfg.max_tokens, + "system": SYSTEM_PROMPT, + "thinking": { "type": "adaptive" }, + "output_config": { "effort": cfg.effort }, + "messages": [ + { "role": "user", "content": user_msg } + ] + }); + serde_json::to_string(&body).map_err(|e| TranslateError::Model(format!("request body: {e}"))) +} + +fn parse_response(raw: &str) -> Result { + let v: serde_json::Value = serde_json::from_str(raw).map_err(|e| { + TranslateError::Model(format!("respuesta no es JSON ({e}): {raw}")) + })?; + // Error envelope de la API: { "type": "error", "error": { "type": "...", "message": "..." } } + if v.get("type").and_then(|t| t.as_str()) == Some("error") { + let err = v + .get("error") + .map(|e| e.to_string()) + .unwrap_or_else(|| "error sin detalle".into()); + return Err(TranslateError::Model(format!("API: {err}"))); + } + // stop_reason "refusal" ⇒ el modelo rechazó. Lo reportamos como Model. + if v.get("stop_reason").and_then(|s| s.as_str()) == Some("refusal") { + let det = v + .get("stop_details") + .map(|d| d.to_string()) + .unwrap_or_else(|| "sin stop_details".into()); + return Err(TranslateError::Model(format!("refusal: {det}"))); + } + let text = extract_first_text(&v)?; + let cleaned = strip_code_fence(&text); + let swm: Swm = serde_json::from_str(cleaned).map_err(|e| { + TranslateError::Model(format!( + "JSON del modelo no parsea como Swm ({e}). Texto:\n{text}" + )) + })?; + swm.verify_schema() + .map_err(|e| TranslateError::Model(format!("schema del Swm: {e}")))?; + Ok(swm) +} + +fn extract_first_text(v: &serde_json::Value) -> Result { + let content = v + .get("content") + .and_then(|c| c.as_array()) + .ok_or_else(|| TranslateError::Model(format!("'content' ausente o no array: {v}")))?; + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("text") { + if let Some(s) = block.get("text").and_then(|t| t.as_str()) { + return Ok(s.to_string()); + } + } + } + Err(TranslateError::Model(format!( + "no encontré un bloque de texto en content: {v}" + ))) +} + +/// Limpia el típico envoltorio ```json ... ```. Best-effort: si no encuentra fence, +/// devuelve el texto sin cambios. También recorta whitespace de los extremos. +fn strip_code_fence(s: &str) -> &str { + let trimmed = s.trim(); + let inside = trimmed + .strip_prefix("```json") + .or_else(|| trimmed.strip_prefix("```")) + .map(|rest| { + rest.trim_start_matches('\n') + .trim_end_matches("```") + .trim_end_matches('\n') + .trim_end() + }) + .unwrap_or(trimmed); + inside.trim() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::sync::{Arc, Mutex}; + + use hammer_core::BaseRef; + + /// Fake HTTP que captura el request y devuelve un body pre-armado. + struct FakeHttp { + captured: Arc, String)>>>, + response: String, + } + + impl HttpClient for FakeHttp { + fn post_json( + &self, + url: &str, + headers: &[(&str, &str)], + body: &str, + ) -> Result { + *self.captured.lock().unwrap() = Some(( + url.to_string(), + headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), + body.to_string(), + )); + Ok(self.response.clone()) + } + } + + fn cfg() -> ClaudeConfig { + ClaudeConfig { + api_key: "sk-test".into(), + model: "claude-opus-4-8".into(), + max_tokens: 4096, + base_url: "https://api.anthropic.com".into(), + effort: "high".into(), + } + } + + fn empty_ctx() -> SystemContext { + SystemContext { + base: BaseRef { + distro_version: "2026-06-06".into(), + pins: BTreeMap::new(), + }, + extras: serde_json::Value::Null, + } + } + + fn ok_response(text: &str) -> String { + serde_json::json!({ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-8", + "content": [{"type": "text", "text": text}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1} + }) + .to_string() + } + + fn swm_text() -> String { + let s = serde_json::json!({ + "swm_version": 1, + "base": {"distro_version": "2026-06-06", "pins": {}}, + "mutations": [{ + "type": "config_edit", + "file": "/etc/network.conf", + "inline_diff": "- DHCP=yes\n+ STATIC=1\n" + }] + }); + serde_json::to_string(&s).unwrap() + } + + #[test] + fn happy_path_emits_v1_messages_post_with_correct_headers() { + let captured = Arc::new(Mutex::new(None)); + let http = FakeHttp { + captured: captured.clone(), + response: ok_response(&swm_text()), + }; + let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); + let swm = t.translate("set static IP", &empty_ctx()).unwrap(); + assert_eq!(swm.mutations.len(), 1); + + let (url, headers, body) = captured.lock().unwrap().clone().unwrap(); + assert_eq!(url, "https://api.anthropic.com/v1/messages"); + assert!(headers.iter().any(|(k, v)| k == "x-api-key" && v == "sk-test")); + assert!(headers + .iter() + .any(|(k, v)| k == "anthropic-version" && v == "2023-06-01")); + assert!(headers + .iter() + .any(|(k, v)| k == "content-type" && v == "application/json")); + let parsed: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(parsed["model"], "claude-opus-4-8"); + assert_eq!(parsed["thinking"]["type"], "adaptive"); + assert_eq!(parsed["output_config"]["effort"], "high"); + let user_msg = parsed["messages"][0]["content"].as_str().unwrap(); + assert!(user_msg.contains("set static IP"), "{user_msg}"); + assert!(user_msg.contains("2026-06-06"), "{user_msg}"); + } + + #[test] + fn unwraps_markdown_code_fence() { + let fenced = format!("```json\n{}\n```", swm_text()); + let http = FakeHttp { + captured: Arc::new(Mutex::new(None)), + response: ok_response(&fenced), + }; + let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); + let swm = t.translate("x", &empty_ctx()).unwrap(); + assert_eq!(swm.mutations.len(), 1); + } + + #[test] + fn refusal_surfaces_as_model_error() { + let resp = serde_json::json!({ + "id": "msg_x", + "type": "message", + "content": [], + "stop_reason": "refusal", + "stop_details": {"category": "cyber", "explanation": "no"}, + "usage": {"input_tokens": 1, "output_tokens": 0} + }); + let http = FakeHttp { + captured: Arc::new(Mutex::new(None)), + response: resp.to_string(), + }; + let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); + let err = t.translate("x", &empty_ctx()).unwrap_err(); + match err { + TranslateError::Model(m) => assert!(m.contains("refusal"), "{m}"), + other => panic!("esperaba Model, llegó {other:?}"), + } + } + + #[test] + fn api_error_envelope_is_reported() { + let resp = serde_json::json!({ + "type": "error", + "error": {"type": "invalid_request_error", "message": "bad model"} + }); + let http = FakeHttp { + captured: Arc::new(Mutex::new(None)), + response: resp.to_string(), + }; + let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); + let err = t.translate("x", &empty_ctx()).unwrap_err(); + match err { + TranslateError::Model(m) => assert!(m.contains("invalid_request_error"), "{m}"), + other => panic!("esperaba Model, llegó {other:?}"), + } + } + + #[test] + fn malformed_swm_surfaces_as_model_error() { + let resp = ok_response("{\"not\": \"a swm\"}"); + let http = FakeHttp { + captured: Arc::new(Mutex::new(None)), + response: resp, + }; + let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); + let err = t.translate("x", &empty_ctx()).unwrap_err(); + match err { + TranslateError::Model(m) => assert!(m.contains("Swm"), "{m}"), + other => panic!("esperaba Model, llegó {other:?}"), + } + } + + #[test] + fn schema_verify_catches_bad_swm() { + // swm_version inválida: el JSON parsea pero verify_schema rechaza. + let bad = serde_json::json!({ + "swm_version": 9, + "base": {"distro_version": "x", "pins": {}}, + "mutations": [] + }); + let http = FakeHttp { + captured: Arc::new(Mutex::new(None)), + response: ok_response(&bad.to_string()), + }; + let t = ClaudeTranslator::with_http(cfg(), Box::new(http)); + let err = t.translate("x", &empty_ctx()).unwrap_err(); + match err { + TranslateError::Model(m) => assert!(m.contains("swm_version"), "{m}"), + other => panic!("esperaba Model, llegó {other:?}"), + } + } + + #[test] + fn strip_code_fence_handles_variants() { + assert_eq!(strip_code_fence("```json\n{\"a\":1}\n```"), "{\"a\":1}"); + assert_eq!(strip_code_fence("```\n{\"a\":1}\n```"), "{\"a\":1}"); + assert_eq!(strip_code_fence("{\"a\":1}"), "{\"a\":1}"); + assert_eq!(strip_code_fence(" {\"a\":1}\n\n"), "{\"a\":1}"); + } +} diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml index e60204ab..820c2674 100644 --- a/crates/hammer-cli/Cargo.toml +++ b/crates/hammer-cli/Cargo.toml @@ -11,6 +11,12 @@ description = "El binario `hammer`: orquesta build, hydrate, try/commit, apply/e name = "hammer" path = "src/main.rs" +[features] +default = [] +# Activa el traductor LLM real (Claude API) detrás del flag `hammer ai --llm`. +# Sin esta feature, `--llm` falla con un mensaje claro pidiendo recompilar. +llm-claude = ["hammer-agent/llm-claude"] + [dependencies] hammer-core.workspace = true hammer-build.workspace = true diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index a80a898e..08cb216e 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -136,12 +136,17 @@ enum Cmd { /// [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). + /// La intención en lenguaje natural. En modo mock (`--catalog`), debe coincidir + /// exactamente con una entrada del catálogo; en modo `--llm`, es texto libre. 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. + /// Catálogo YAML de intent → swm (MockTranslator). Mutuamente excluyente con `--llm`. #[arg(long)] - catalog: PathBuf, + catalog: Option, + /// Activa el traductor LLM real (Claude API). Requiere `ANTHROPIC_API_KEY` en el env + /// y un binario compilado con `--features llm-claude`. Modelo por defecto: + /// `claude-opus-4-8` (sobreescribible con `HAMMER_LLM_MODEL`). + #[arg(long)] + llm: bool, /// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para /// dev/CI sin root ni overlayfs. #[arg(long)] @@ -364,6 +369,7 @@ fn main() -> anyhow::Result<()> { Cmd::Ai { intent, catalog, + llm, prefix, base_ref, bus, @@ -373,7 +379,8 @@ fn main() -> anyhow::Result<()> { } => { run_ai( &intent, - &catalog, + catalog.as_deref(), + llm, prefix.as_deref(), base_ref.as_deref(), bus.as_deref(), @@ -684,7 +691,8 @@ fn run_export( /// `Proposal` legible en stdout + el siguiente paso recomendado al humano. fn run_ai( intent: &str, - catalog: &std::path::Path, + catalog: Option<&std::path::Path>, + llm: bool, prefix: Option<&std::path::Path>, base_ref: Option<&std::path::Path>, bus: Option<&std::path::Path>, @@ -693,43 +701,26 @@ fn run_ai( repair_max_attempts: Option, repair_window_ms: u64, ) -> anyhow::Result<()> { - use hammer_agent::{ - orchestrator::{ApplyTarget, CompileMode, RepairPolicy}, - IntentCatalog, MockTranslator, Orchestrator, - }; + use hammer_agent::orchestrator::{ApplyTarget, CompileMode, RepairPolicy}; - 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}"))?; + if llm && catalog.is_some() { + anyhow::bail!("--llm y --catalog son mutuamente excluyentes"); + } + if !llm && catalog.is_none() { + anyhow::bail!("hace falta --catalog (modo mock) o --llm (modo LLM)"); + } - // 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"), - } + // Calcular BaseRef. En modo catálogo se puede inferir del .swm; en modo LLM hace falta + // --base-ref explícito porque el modelo no ha producido nada aún. + let base = if llm { + match load_local_base(base_ref)? { + Some(b) => b, + None => anyhow::bail!( + "modo --llm requiere --base-ref para que el traductor reciba un SystemContext válido" + ), } + } else { + infer_base_for_catalog(intent, catalog.unwrap(), base_ref)? }; let apply = match prefix { @@ -750,21 +741,24 @@ fn run_ai( None => CompileMode::Skip, }; - let orch = Orchestrator::new(translator, base, apply, compile); - let proposal = match repair_max_attempts { - Some(max) => { - let policy = RepairPolicy { - max_attempts: max, - crash_window: std::time::Duration::from_millis(repair_window_ms), - event_sock: bus.map(|p| p.to_path_buf()), - ..RepairPolicy::default() - }; - orch.run_with_repair(intent, &policy) - .map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}"))? - } - None => orch - .run(intent) - .map_err(|e| anyhow::anyhow!("orchestrator: {e}"))?, + let policy_opt = repair_max_attempts.map(|max| RepairPolicy { + max_attempts: max, + crash_window: std::time::Duration::from_millis(repair_window_ms), + event_sock: bus.map(|p| p.to_path_buf()), + ..RepairPolicy::default() + }); + + let proposal = if llm { + run_with_llm_translator(intent, base, apply, compile, policy_opt.as_ref())? + } else { + run_with_mock_translator( + intent, + catalog.unwrap(), + base, + apply, + compile, + policy_opt.as_ref(), + )? }; println!("--- proposal ---"); @@ -816,6 +810,102 @@ fn run_ai( Ok(()) } +/// En modo catálogo, intenta deducir un BaseRef si el humano no pasó `--base-ref`: +/// usa la `base` del propio `.swm` mockeado. Esto facilita el smoke-test offline. +fn infer_base_for_catalog( + intent: &str, + catalog: &std::path::Path, + base_ref: Option<&std::path::Path>, +) -> anyhow::Result { + if let Some(b) = load_local_base(base_ref)? { + return Ok(b); + } + let cat = hammer_agent::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 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) => Ok(hammer_core::BaseRef::from(&s.base)), + None => anyhow::bail!("no pude inferir BaseRef sin --base-ref"), + } +} + +fn run_with_mock_translator( + intent: &str, + catalog: &std::path::Path, + base: hammer_core::BaseRef, + apply: hammer_agent::orchestrator::ApplyTarget, + compile: hammer_agent::orchestrator::CompileMode, + policy: Option<&hammer_agent::orchestrator::RepairPolicy>, +) -> anyhow::Result { + use hammer_agent::{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}"))?; + let orch = Orchestrator::new(translator, base, apply, compile); + match policy { + Some(p) => orch + .run_with_repair(intent, p) + .map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}")), + None => orch + .run(intent) + .map_err(|e| anyhow::anyhow!("orchestrator: {e}")), + } +} + +#[cfg(feature = "llm-claude")] +fn run_with_llm_translator( + intent: &str, + base: hammer_core::BaseRef, + apply: hammer_agent::orchestrator::ApplyTarget, + compile: hammer_agent::orchestrator::CompileMode, + policy: Option<&hammer_agent::orchestrator::RepairPolicy>, +) -> anyhow::Result { + use hammer_agent::{ClaudeConfig, ClaudeTranslator, Orchestrator}; + let cfg = ClaudeConfig::from_env() + .map_err(|e| anyhow::anyhow!("ClaudeConfig::from_env: {e}"))?; + let translator = ClaudeTranslator::new(cfg); + let orch = Orchestrator::new(translator, base, apply, compile); + match policy { + Some(p) => orch + .run_with_repair(intent, p) + .map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}")), + None => orch + .run(intent) + .map_err(|e| anyhow::anyhow!("orchestrator: {e}")), + } +} + +#[cfg(not(feature = "llm-claude"))] +fn run_with_llm_translator( + _intent: &str, + _base: hammer_core::BaseRef, + _apply: hammer_agent::orchestrator::ApplyTarget, + _compile: hammer_agent::orchestrator::CompileMode, + _policy: Option<&hammer_agent::orchestrator::RepairPolicy>, +) -> anyhow::Result { + anyhow::bail!( + "este binario se compiló sin el feature `llm-claude`. Recompila: \ + `cargo build -p hammer-cli --features llm-claude` (o desactiva --llm)." + ) +} + /// Evalúa una expresión del mini-lenguaje localmente e imprime el JSON resultante. /// Espejo en proceso del path remoto vía bus (`AgentClient::query_expr`). fn run_query( diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 04e67b64..fbeb5a9e 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -117,7 +117,11 @@ pre-requisito de validación. - 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. +- [x] Traductor LLM real (Claude API u otro), opcional vía feature flag o crate aparte. + `ClaudeTranslator` detrás de la feature `llm-claude`, con `ureq + rustls`. Modelo + por defecto `claude-opus-4-8`, adaptive thinking + `effort=high`. Trait `HttpClient` + inyectable para tests sin red. CLI: `hammer ai --llm` (mutuamente excluyente con + `--catalog`). Sin la feature, el flag falla con mensaje claro. - [x] Lenguaje de consulta del sistema (SDD 08 §6) para que la IA refiera servicios y archivos sin rutas frágiles. Forma `kind:value` (`bin`, `file`, `pin`, `service`, `depends`), evaluable local (`hammer query `) y remoto vía bus