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:
Generated
+472
-7
@@ -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,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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user