Fase 6 — traductor LLM real (Claude API) detrás de feature llm-claude

`ClaudeTranslator` implementa el trait `IntentTranslator` igual que el
`MockTranslator`, así que el `Orchestrator` no cambia: se traduce una
intención NL a un `.swm` válido vía `/v1/messages`.

Diseño:
- Síncrono (ureq + rustls), consistente con `AgentClient`. Tokio
  entraría sólo si el resto del crate lo pidiera.
- Trait `HttpClient` inyectable ⇒ tests sin red contra una fake que
  captura el request y devuelve un body pre-armado.
- Modelo por defecto: `claude-opus-4-8` (Opus 4.8, el más capaz al día
  de hoy). Adaptive thinking + `effort=high` por defecto. Override por
  env (`HAMMER_LLM_MODEL`, `HAMMER_LLM_EFFORT`, `HAMMER_LLM_BASE_URL`).
- System prompt documenta el shape exacto del `.swm` (4 variantes de
  mutación) y obliga JSON puro. Parseamos con `serde_json::from_str::
  <Swm>` + `verify_schema()` como gate adicional. Manejamos `refusal`,
  error envelopes de la API y code-fence markdown.

Activación:
- Sin feature: el módulo declara los tipos pero `ClaudeTranslator::new`
  está bajo cfg. El binario compila sin red.
- Con `--features llm-claude`: trae `ureq` con rustls, y la CLI activa
  `hammer ai --llm`.

CLI:
- `hammer ai` gana `--llm` (mutuamente excluyente con `--catalog`).
  Refactor de `run_ai` en helpers `run_with_mock_translator` /
  `run_with_llm_translator` para mantener legible el dispatch.
- Sin la feature, `--llm` falla con un mensaje claro pidiendo
  recompilar.

Tests (7 unit): happy path con verificación de URL/headers/body,
unwrap de markdown, `refusal`, error envelope, SWM mal formado,
verify_schema, helpers de strip_code_fence.
This commit is contained in:
Sergio
2026-06-10 16:29:27 +00:00
parent f769d9173c
commit 5986200539
7 changed files with 1110 additions and 63 deletions
Generated
+472 -7
View File
@@ -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"
+7
View File
@@ -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
+2
View File
@@ -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 {
@@ -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::<Swm>`) 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<String, TranslateError>;
}
#[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<String, TranslateError> {
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<Self, TranslateError> {
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<dyn HttpClient>,
}
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<dyn HttpClient>) -> 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": "<copia el distro_version del SystemContext>",
"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:<hex>","content_b64":"<base64>"}
- `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":"<sha>","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<Swm, TranslateError> {
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<String, TranslateError> {
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<Swm, TranslateError> {
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<String, TranslateError> {
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<Mutex<Option<(String, Vec<(String, String)>, String)>>>,
response: String,
}
impl HttpClient for FakeHttp {
fn post_json(
&self,
url: &str,
headers: &[(&str, &str)],
body: &str,
) -> Result<String, TranslateError> {
*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}");
}
}
+6
View File
@@ -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
+145 -55
View File
@@ -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<PathBuf>,
/// 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<u32>,
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<hammer_core::BaseRef> {
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<hammer_agent::Proposal> {
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<hammer_agent::Proposal> {
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<hammer_agent::Proposal> {
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(
+5 -1
View File
@@ -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 <expr>`) y remoto vía bus