diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 908b7b9d..3189bd87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: cache-targets: "false" - uses: mozilla-actions/sccache-action@v0.0.9 - name: Clippy - run: cargo clippy --workspace -- -D warnings + run: cargo clippy --workspace --features full -- -D warnings env: RUSTC_WRAPPER: sccache SCCACHE_GHA_ENABLED: "true" @@ -67,7 +67,12 @@ jobs: - uses: mozilla-actions/sccache-action@v0.0.9 - uses: taiki-e/install-action@nextest - name: Run tests - run: cargo nextest run --workspace --lib --bins + run: cargo nextest run --workspace --features full --lib --bins + env: + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: "true" + - name: Run doc tests + run: cargo test --workspace --features full --doc env: RUSTC_WRAPPER: sccache SCCACHE_GHA_ENABLED: "true" @@ -86,7 +91,7 @@ jobs: - uses: mozilla-actions/sccache-action@v0.0.9 - uses: taiki-e/install-action@nextest - name: Run integration tests (testcontainers) - run: cargo nextest run --workspace --profile ci --test '*integration*' + run: cargo nextest run --workspace --features full --profile ci --test '*integration*' env: RUSTC_WRAPPER: sccache SCCACHE_GHA_ENABLED: "true" @@ -106,7 +111,7 @@ jobs: - uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@nextest - name: Generate coverage - run: cargo llvm-cov nextest --workspace --lib --bins --lcov --output-path lcov.info + run: cargo llvm-cov nextest --workspace --features full --lib --bins --lcov --output-path lcov.info env: RUSTC_WRAPPER: sccache SCCACHE_GHA_ENABLED: "true" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d06e71d..ed542846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- `ProviderKind` enum for type-safe provider selection in config +- `RuntimeConfig` struct grouping agent runtime fields +- `AnyProvider::embed_fn()` shared embedding closure helper +- `Config::validate()` with bounds checking for critical config values +- `sanitize_paths()` for stripping absolute paths from error messages +- 10-second timeout wrapper for embedding API calls +- `full` feature flag enabling all optional features + +### Changed +- `AnyChannel` moved from main.rs to zeph-channels crate +- Default features reduced to minimal set (qdrant, self-learning, vault-age, compatible, index) +- Skill matcher concurrency reduced from 50 to 20 +- `String::with_capacity` in context building loops +- CI updated to use `--features full` + +### Breaking +- `LlmConfig.provider` changed from `String` to `ProviderKind` enum +- Default features reduced -- users needing a2a, candle, mcp, openai, orchestrator, router, tui must enable explicitly or use `--features full` +- Telegram channel rejects empty `allowed_users` at startup +- Config with extreme values now rejected by `Config::validate()` + +### Deprecated +- `ToolExecutor::execute()` string-based dispatch (use `execute_tool_call()` instead) + +### Fixed +- Closed #410 (clap dropped atty), #411 (rmcp updated quinn-udp), #413 (A2A body limit already present) + ## [0.9.9] - 2026-02-17 ### Added diff --git a/Cargo.lock b/Cargo.lock index 1988ea67..832d5039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,7 +137,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -205,7 +205,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -216,7 +216,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -379,7 +379,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -622,7 +622,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -861,18 +861,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.58" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.58" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" dependencies = [ "anstyle", "clap_lex", @@ -1202,7 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1249,7 +1249,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1293,7 +1293,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1307,7 +1307,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1320,7 +1320,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1331,7 +1331,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1342,7 +1342,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1353,7 +1353,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1416,7 +1416,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1426,7 +1426,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1455,7 +1455,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", "unicode-xid", ] @@ -1469,7 +1469,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1523,7 +1523,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1638,7 +1638,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1952,7 +1952,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -1985,21 +1985,11 @@ dependencies = [ "libc", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -2012,9 +2002,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2028,9 +2018,9 @@ checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -2050,38 +2040,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2091,7 +2081,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2554,9 +2543,9 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", "markup5ever", @@ -2764,7 +2753,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.115", + "syn 2.0.116", "unic-langid", ] @@ -2778,7 +2767,7 @@ dependencies = [ "i18n-config", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -3038,7 +3027,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -3320,12 +3309,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mac_address" version = "1.1.8" @@ -3363,9 +3346,9 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", "tendril", @@ -3374,9 +3357,9 @@ dependencies = [ [[package]] name = "markup5ever_rcdom" -version = "0.36.0+unofficial" +version = "0.38.0+unofficial" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e5fc8802e8797c0dfdd2ce5c21aa0aee21abbc7b3b18559100651b3352a7b63" +checksum = "333171ccdf66e915257740d44e38ea5b1b19ce7b45d33cc35cb6f118fbd981ff" dependencies = [ "html5ever", "markup5ever", @@ -3524,7 +3507,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -3680,7 +3663,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -4015,7 +3998,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -4085,7 +4068,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -4169,7 +4152,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -4182,7 +4165,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -4220,7 +4203,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -4344,7 +4327,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -4366,7 +4349,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -4422,7 +4405,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -4435,7 +4418,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -4868,7 +4851,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -5046,7 +5029,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -5089,7 +5072,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.115", + "syn 2.0.116", "walkdir", ] @@ -5327,7 +5310,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -5338,9 +5321,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scrape-core" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b93652c0036259a932e7ae64d8e1dc30a7f74aebfe947bf18275039ae12abf59" +checksum = "d53cd0cb49d13b913b2f7a6da2a302ae3cb85b3550c0d7f283fec21873edf43a" dependencies = [ "cssparser", "html5ever", @@ -5474,7 +5457,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -5485,7 +5468,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -5530,7 +5513,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -5582,7 +5565,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -5608,7 +5591,7 @@ checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -5852,7 +5835,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -5873,7 +5856,7 @@ dependencies = [ "sha2", "sqlx-core", "sqlx-sqlite", - "syn 2.0.115", + "syn 2.0.116", "tokio", "url", ] @@ -6034,6 +6017,7 @@ dependencies = [ "parking_lot", "phf_shared 0.13.1", "precomputed-hash", + "serde", ] [[package]] @@ -6074,7 +6058,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -6085,7 +6069,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -6106,7 +6090,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -6128,9 +6112,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.115" +version = "2.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" dependencies = [ "proc-macro2", "quote", @@ -6154,7 +6138,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -6271,7 +6255,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -6289,12 +6273,11 @@ dependencies = [ [[package]] name = "tendril" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" dependencies = [ - "futf", - "mac", + "new_debug_unreachable", "utf-8", ] @@ -6418,7 +6401,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -6429,7 +6412,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -6577,7 +6560,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -6641,9 +6624,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.1+spec-1.1.0" +version = "1.0.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe30f93627849fa362d4a602212d41bb237dc2bd0f8ba0b2ce785012e124220" +checksum = "d1dfefef6a142e93f346b64c160934eb13b5594b84ab378133ac6815cb2bd57f" dependencies = [ "indexmap 2.13.0", "serde_core", @@ -6665,9 +6648,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.8+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow 0.7.14", ] @@ -6843,7 +6826,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -7156,9 +7139,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -7448,7 +7431,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", "wasm-bindgen-shared", ] @@ -7746,7 +7729,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -7757,7 +7740,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -8156,7 +8139,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn 2.0.115", + "syn 2.0.116", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -8172,7 +8155,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -8244,9 +8227,9 @@ dependencies = [ [[package]] name = "xml5ever" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f57dd51b88a4b9f99f9b55b136abb86210629d61c48117ddb87f567e51e66be7" +checksum = "d3dc9559429edf0cd3f327cc0afd9d6b36fa8cec6d93107b7fbe64f806b5f2d9" dependencies = [ "log", "markup5ever", @@ -8283,7 +8266,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", "synstructure", ] @@ -8295,7 +8278,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", "synstructure", ] @@ -8389,7 +8372,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", - "toml 1.0.1+spec-1.1.0", + "toml 1.0.2+spec-1.1.0", "tracing", "zeph-index", "zeph-llm", @@ -8559,7 +8542,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "toml 1.0.1+spec-1.1.0", + "toml 1.0.2+spec-1.1.0", "tracing", "url", "uuid", @@ -8605,7 +8588,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -8625,7 +8608,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", "synstructure", ] @@ -8646,7 +8629,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] @@ -8680,7 +8663,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.116", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1029eb3b..6b99a0d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,7 +103,8 @@ license.workspace = true repository.workspace = true [features] -default = ["a2a", "candle", "compatible", "index", "mcp", "openai", "orchestrator", "qdrant", "router", "self-learning", "vault-age"] +default = ["compatible", "openai", "qdrant", "self-learning", "vault-age"] +full = ["a2a", "compatible", "discord", "gateway", "index", "mcp", "openai", "orchestrator", "qdrant", "router", "self-learning", "slack", "tui", "vault-age"] a2a = ["dep:zeph-a2a", "zeph-a2a?/server"] compatible = ["zeph-llm/compatible"] mcp = ["dep:zeph-mcp", "zeph-core/mcp"] diff --git a/README.md b/README.md index 9df8f366..59bcc12c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![MSRV](https://img.shields.io/badge/MSRV-1.88-blue)](https://www.rust-lang.org) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -Lightweight AI agent that routes tasks across **Ollama, Claude, OpenAI, and HuggingFace** models — with semantic skill matching, vector memory, MCP tooling, and agent-to-agent communication. Ships as a single binary for Linux, macOS, and Windows. +Lightweight AI agent that routes tasks across **Ollama, Claude, OpenAI, HuggingFace, and OpenAI-compatible endpoints** (Together AI, Groq, etc.) — with semantic skill matching, vector memory, MCP tooling, and agent-to-agent communication. Ships as a single binary for Linux, macOS, and Windows.
Zeph @@ -19,7 +19,7 @@ Lightweight AI agent that routes tasks across **Ollama, Claude, OpenAI, and Hugg **Intelligent context management.** Two-tier context pruning: Tier 1 selectively removes old tool outputs (clearing bodies from memory after persisting to SQLite) before falling back to Tier 2 LLM-based compaction, reducing unnecessary LLM calls. A token-based protection zone preserves recent context from pruning. Parallel context preparation via `try_join!` and optimized byte-length token estimation. Cross-session memory transfers knowledge between conversations with relevance filtering. Proportional budget allocation (8% summaries, 8% semantic recall, 4% cross-session, 30% code context, 50% recent history) keeps conversations efficient. Tool outputs are truncated at 30K chars with optional LLM-based summarization for large outputs. Doom-loop detection breaks runaway tool cycles after 3 identical consecutive outputs, with configurable iteration limits (default 10). ZEPH.md project config discovery walks up the directory tree and injects project-specific context when available. Config hot-reload applies runtime-safe fields (timeouts, security, memory limits) on file change without restart. -**Run anywhere.** Local models via Ollama or Candle (GGUF with Metal/CUDA), cloud APIs (Claude, OpenAI, GPT-compatible endpoints like Together AI and Groq), or all of them at once through the multi-model orchestrator with automatic fallback chains. +**Run anywhere.** Local models via Ollama or Candle (GGUF with Metal/CUDA), cloud APIs (Claude, OpenAI), OpenAI-compatible endpoints (Together AI, Groq, Fireworks) via `CompatibleProvider`, or all of them at once through the multi-model orchestrator with automatic fallback chains and `RouterProvider` for prompt-based model selection. **Production-ready security.** Shell sandboxing with path restrictions and relative path traversal detection, pattern-based permission policy per tool, destructive command confirmation, file operation sandbox with path traversal protection, tool output overflow-to-file (with LLM-accessible paths), secret redaction (AWS, OpenAI, Anthropic, Google, GitLab), audit logging, SSRF protection (including MCP client), rate limiter with TTL-based eviction, and Trivy-scanned container images with 0 HIGH/CRITICAL CVEs. @@ -72,8 +72,12 @@ For cloud providers: # Claude ZEPH_LLM_PROVIDER=claude ZEPH_CLAUDE_API_KEY=sk-ant-... ./target/release/zeph -# OpenAI (or any compatible API) +# OpenAI ZEPH_LLM_PROVIDER=openai ZEPH_OPENAI_API_KEY=sk-... ./target/release/zeph + +# OpenAI-compatible endpoint (Together AI, Groq, Fireworks, etc.) +ZEPH_LLM_PROVIDER=compatible ZEPH_COMPATIBLE_BASE_URL=https://api.together.xyz/v1 \ + ZEPH_COMPATIBLE_API_KEY=... ./target/release/zeph ``` For Discord or Slack bot mode (requires respective feature): @@ -101,7 +105,7 @@ cargo build --release --features tui | Feature | Description | Docs | |---------|-------------|------| | **Native Tool Use** | Structured tool calling via Claude tool_use and OpenAI function calling APIs; automatic fallback to text extraction for local models | [Tools](https://bug-ops.github.io/zeph/guide/tools.html) | -| **Hybrid Inference** | Ollama, Claude, OpenAI, Candle (GGUF) — local, cloud, or both | [OpenAI](https://bug-ops.github.io/zeph/guide/openai.html) · [Candle](https://bug-ops.github.io/zeph/guide/candle.html) | +| **Hybrid Inference** | Ollama, Claude, OpenAI, Candle (GGUF), Compatible (any OpenAI-compatible API) — local, cloud, or both | [OpenAI](https://bug-ops.github.io/zeph/guide/openai.html) · [Candle](https://bug-ops.github.io/zeph/guide/candle.html) | | **Skills-First Architecture** | Embedding-based top-K matching, progressive loading, hot-reload | [Skills](https://bug-ops.github.io/zeph/guide/skills.html) | | **Code Indexing** | AST-based chunking (tree-sitter), semantic retrieval, repo map generation, incremental indexing | [Code Indexing](https://bug-ops.github.io/zeph/guide/code-indexing.html) | | **Context Engineering** | Two-tier context pruning (selective tool-output pruning before LLM compaction), semantic recall injection, proportional budget allocation, token-based protection zone for recent context, config hot-reload | [Context](https://bug-ops.github.io/zeph/guide/context.html) · [Configuration](https://bug-ops.github.io/zeph/getting-started/configuration.html) | @@ -120,15 +124,15 @@ cargo build --release --features tui ## Architecture ``` -zeph (binary) — bootstrap, AnyChannel dispatch, vault resolution (anyhow for top-level errors) +zeph (binary) — bootstrap, vault resolution (anyhow for top-level errors) ├── zeph-core — Agent split into 7 submodules (context, streaming, persistence, │ learning, mcp, index), daemon supervisor, typed AgentError/ChannelError, config hot-reload -├── zeph-llm — LlmProvider: Ollama, Claude, OpenAI, Candle, orchestrator, -│ native tool_use (Claude/OpenAI), typed LlmError +├── zeph-llm — LlmProvider: Ollama, Claude, OpenAI, Candle, Compatible, orchestrator, +│ RouterProvider, native tool_use (Claude/OpenAI), typed LlmError ├── zeph-skills — SKILL.md parser, embedding matcher, hot-reload, self-learning, typed SkillError ├── zeph-memory — SQLite + Qdrant, semantic recall, summarization, typed MemoryError ├── zeph-index — AST-based code indexing, semantic retrieval, repo map (optional) -├── zeph-channels — Discord, Slack, Telegram adapters with streaming +├── zeph-channels — AnyChannel dispatch, Discord, Slack, Telegram adapters with streaming ├── zeph-tools — schemars-driven tool registry (shell, file ops, web scrape), composite dispatch ├── zeph-mcp — MCP client, multi-server lifecycle, unified tool matching ├── zeph-a2a — A2A client + server, agent discovery, JSON-RPC 2.0 @@ -137,7 +141,7 @@ zeph (binary) — bootstrap, AnyChannel dispatch, vault resolution (anyhow for t └── zeph-tui — ratatui TUI dashboard with live agent metrics (optional) ``` -**Error handling:** Typed errors throughout all library crates -- `AgentError` (7 variants), `ChannelError` (4 variants), `LlmError`, `MemoryError`, `SkillError`. `anyhow` is used only in `main.rs` for top-level orchestration. Shared Qdrant operations consolidated via `QdrantOps` helper. `AnyProvider` dispatch deduplicated via `delegate_provider!` macro. +**Error handling:** Typed errors throughout all library crates -- `AgentError` (7 variants), `ChannelError` (4 variants), `LlmError`, `MemoryError`, `SkillError`. `anyhow` is used only in `main.rs` for top-level orchestration. Shared Qdrant operations consolidated via `QdrantOps` helper. `AnyProvider` dispatch deduplicated via `delegate_provider!` macro. `AnyChannel` enum dispatch lives in `zeph-channels` for reuse across binaries. **Agent decomposition:** The agent module in `zeph-core` is split into 7 submodules (`mod.rs`, `context.rs`, `streaming.rs`, `persistence.rs`, `learning.rs`, `mcp.rs`, `index.rs`) with 5 inner field-grouping structs (`MemoryState`, `SkillState`, `ContextState`, `McpState`, `IndexState`). @@ -152,29 +156,32 @@ Deep dive: [Architecture overview](https://bug-ops.github.io/zeph/architecture/o | Feature | Default | Description | |---------|---------|-------------| -| `a2a` | On | A2A protocol client and server | -| `openai` | On | OpenAI-compatible provider | -| `mcp` | On | MCP client for external tool servers | -| `candle` | On | Local HuggingFace inference (GGUF) | -| `orchestrator` | On | Multi-model routing with fallback | -| `qdrant` | On | Qdrant vector search for skills and MCP tools (opt-out) | +| `compatible` | On | OpenAI-compatible provider (Together AI, Groq, Fireworks, etc.) | +| `openai` | On | OpenAI provider | +| `qdrant` | On | Qdrant vector search for skills and MCP tools | | `self-learning` | On | Skill evolution system | | `vault-age` | On | Age-encrypted secret storage | -| `index` | On | AST-based code indexing and semantic retrieval | +| `a2a` | Off | A2A protocol client and server | +| `candle` | Off | Local HuggingFace inference (GGUF) | +| `index` | Off | AST-based code indexing and semantic retrieval | +| `mcp` | Off | MCP client for external tool servers | +| `orchestrator` | Off | Multi-model routing with fallback | +| `router` | Off | Prompt-based model selection via RouterProvider | | `discord` | Off | Discord bot with Gateway v10 WebSocket | | `slack` | Off | Slack bot with Events API webhook | | `gateway` | Off | HTTP gateway for webhook ingestion | | `daemon` | Off | Daemon supervisor for component lifecycle | | `scheduler` | Off | Cron-based periodic task scheduler | +| `otel` | Off | OpenTelemetry OTLP export for Prometheus/Grafana | | `metal` | Off | Metal GPU acceleration (macOS) | | `tui` | Off | ratatui TUI dashboard with real-time metrics | | `cuda` | Off | CUDA GPU acceleration (Linux) | ```bash -cargo build --release # all defaults +cargo build --release # default features only +cargo build --release --features full # all non-platform features cargo build --release --features metal # macOS Metal GPU -cargo build --release --no-default-features # minimal binary -cargo build --release --features index # with code indexing +cargo build --release --no-default-features # minimal binary (Ollama + Claude only) cargo build --release --features tui # with TUI dashboard ``` diff --git a/crates/zeph-channels/src/any.rs b/crates/zeph-channels/src/any.rs new file mode 100644 index 00000000..c99ec04c --- /dev/null +++ b/crates/zeph-channels/src/any.rs @@ -0,0 +1,77 @@ +use zeph_core::channel::{Channel, ChannelError, ChannelMessage}; + +use crate::cli::CliChannel; +#[cfg(feature = "discord")] +use crate::discord::DiscordChannel; +#[cfg(feature = "slack")] +use crate::slack::SlackChannel; +use crate::telegram::TelegramChannel; + +/// Enum dispatch for runtime channel selection. +#[derive(Debug)] +pub enum AnyChannel { + Cli(CliChannel), + Telegram(TelegramChannel), + #[cfg(feature = "discord")] + Discord(DiscordChannel), + #[cfg(feature = "slack")] + Slack(SlackChannel), +} + +macro_rules! dispatch_channel { + ($self:expr, $method:ident $(, $arg:expr)*) => { + match $self { + AnyChannel::Cli(c) => c.$method($($arg),*).await, + AnyChannel::Telegram(c) => c.$method($($arg),*).await, + #[cfg(feature = "discord")] + AnyChannel::Discord(c) => c.$method($($arg),*).await, + #[cfg(feature = "slack")] + AnyChannel::Slack(c) => c.$method($($arg),*).await, + } + }; +} + +impl Channel for AnyChannel { + async fn recv(&mut self) -> Result, ChannelError> { + dispatch_channel!(self, recv) + } + + async fn send(&mut self, text: &str) -> Result<(), ChannelError> { + dispatch_channel!(self, send, text) + } + + async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> { + dispatch_channel!(self, send_chunk, chunk) + } + + async fn flush_chunks(&mut self) -> Result<(), ChannelError> { + dispatch_channel!(self, flush_chunks) + } + + async fn send_typing(&mut self) -> Result<(), ChannelError> { + dispatch_channel!(self, send_typing) + } + + async fn confirm(&mut self, prompt: &str) -> Result { + dispatch_channel!(self, confirm, prompt) + } + + fn try_recv(&mut self) -> Option { + match self { + Self::Cli(c) => c.try_recv(), + Self::Telegram(c) => c.try_recv(), + #[cfg(feature = "discord")] + Self::Discord(c) => c.try_recv(), + #[cfg(feature = "slack")] + Self::Slack(c) => c.try_recv(), + } + } + + async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> { + dispatch_channel!(self, send_status, text) + } + + async fn send_queue_count(&mut self, count: usize) -> Result<(), ChannelError> { + dispatch_channel!(self, send_queue_count, count) + } +} diff --git a/crates/zeph-channels/src/lib.rs b/crates/zeph-channels/src/lib.rs index da9653c0..0c2b0e72 100644 --- a/crates/zeph-channels/src/lib.rs +++ b/crates/zeph-channels/src/lib.rs @@ -1,5 +1,6 @@ //! Channel implementations for the Zeph agent. +mod any; pub mod cli; #[cfg(feature = "discord")] pub mod discord; @@ -9,5 +10,6 @@ pub mod markdown; pub mod slack; pub mod telegram; +pub use any::AnyChannel; pub use cli::CliChannel; pub use error::ChannelError; diff --git a/crates/zeph-channels/src/telegram.rs b/crates/zeph-channels/src/telegram.rs index 67599c05..3e0b7348 100644 --- a/crates/zeph-channels/src/telegram.rs +++ b/crates/zeph-channels/src/telegram.rs @@ -48,6 +48,13 @@ impl TelegramChannel { /// /// Returns an error if the bot cannot be initialized. pub fn start(mut self) -> Result { + if self.allowed_users.is_empty() { + tracing::error!("telegram.allowed_users is empty; refusing to start an open bot"); + return Err(ChannelError::Other( + "telegram.allowed_users must not be empty".into(), + )); + } + let (tx, rx) = mpsc::channel::(64); self.rx = rx; diff --git a/crates/zeph-core/src/agent/context.rs b/crates/zeph-core/src/agent/context.rs index 57b97834..93ff0487 100644 --- a/crates/zeph-core/src/agent/context.rs +++ b/crates/zeph-core/src/agent/context.rs @@ -329,7 +329,8 @@ impl Agent Agent Agent { pub(super) repo_map_ttl: std::time::Duration, } +pub(super) struct RuntimeConfig { + pub(super) security: SecurityConfig, + pub(super) timeouts: TimeoutConfig, + pub(super) model_name: String, + pub(super) max_tool_iterations: usize, + pub(super) summarize_tool_output_enabled: bool, + pub(super) permission_policy: zeph_tools::PermissionPolicy, +} + pub struct Agent { provider: P, channel: C, @@ -108,9 +117,7 @@ pub struct Agent config_reload_rx: Option>, shutdown: watch::Receiver, metrics_tx: Option>, - security: SecurityConfig, - timeouts: TimeoutConfig, - model_name: String, + pub(super) runtime: RuntimeConfig, #[cfg(feature = "self-learning")] learning_config: Option, #[cfg(feature = "self-learning")] @@ -121,11 +128,8 @@ pub struct Agent pub(super) index: IndexState

, start_time: Instant, message_queue: VecDeque, - summarize_tool_output_enabled: bool, summary_provider: Option

, - permission_policy: zeph_tools::PermissionPolicy, warmup_ready: Option>, - max_tool_iterations: usize, doom_loop_history: Vec, cost_tracker: Option, } @@ -188,9 +192,14 @@ impl Agent Agent Agent Self { - self.max_tool_iterations = max; + self.runtime.max_tool_iterations = max; self } @@ -308,14 +314,14 @@ impl Agent Self { - self.security = security; - self.timeouts = timeouts; + self.runtime.security = security; + self.runtime.timeouts = timeouts; self } #[must_use] pub fn with_tool_summarization(mut self, enabled: bool) -> Self { - self.summarize_tool_output_enabled = enabled; + self.runtime.summarize_tool_output_enabled = enabled; self } @@ -331,7 +337,7 @@ impl Agent Self { - self.permission_policy = policy; + self.runtime.permission_policy = policy; self } @@ -355,7 +361,7 @@ impl Agent) -> Self { - self.model_name = name.into(); + self.runtime.model_name = name.into(); self } @@ -388,7 +394,7 @@ impl Agent) -> Self { let provider_name = self.provider.name().to_string(); - let model_name = self.model_name.clone(); + let model_name = self.runtime.model_name.clone(); let total_skills = self.skill_state.registry.all_meta().len(); let qdrant_available = self .memory_state @@ -441,7 +447,7 @@ impl Agent Agent Agent Agent Agent Agent Agent(&self, text: &'a str) -> std::borrow::Cow<'a, str> { - if self.security.redact_secrets { - redact_secrets(text) + if self.runtime.security.redact_secrets { + let redacted = redact_secrets(text); + let sanitized = crate::redact::sanitize_paths(&redacted); + match sanitized { + std::borrow::Cow::Owned(s) => std::borrow::Cow::Owned(s), + std::borrow::Cow::Borrowed(_) => redacted, + } } else { std::borrow::Cow::Borrowed(text) } @@ -419,7 +424,7 @@ impl Agent Agent anyhow::Result<()> { + anyhow::ensure!( + self.memory.history_limit <= 10_000, + "history_limit must be <= 10000, got {}", + self.memory.history_limit + ); + if self.memory.context_budget_tokens > 0 { + anyhow::ensure!( + self.memory.context_budget_tokens <= 1_000_000, + "context_budget_tokens must be <= 1000000, got {}", + self.memory.context_budget_tokens + ); + } + anyhow::ensure!( + self.agent.max_tool_iterations <= 100, + "max_tool_iterations must be <= 100, got {}", + self.agent.max_tool_iterations + ); + anyhow::ensure!(self.a2a.rate_limit > 0, "a2a.rate_limit must be > 0"); + anyhow::ensure!( + self.gateway.rate_limit > 0, + "gateway.rate_limit must be > 0" + ); + Ok(()) + } + /// Resolve sensitive configuration values through the vault. /// /// # Errors diff --git a/crates/zeph-core/src/config/tests.rs b/crates/zeph-core/src/config/tests.rs index ceb8ba05..d697f2e8 100644 --- a/crates/zeph-core/src/config/tests.rs +++ b/crates/zeph-core/src/config/tests.rs @@ -64,7 +64,7 @@ fn clear_env() { #[test] fn defaults_when_file_missing() { let config = Config::default(); - assert_eq!(config.llm.provider, "ollama"); + assert_eq!(config.llm.provider, super::ProviderKind::Ollama); assert_eq!(config.llm.base_url, "http://localhost:11434"); assert_eq!(config.llm.model, "mistral:7b"); assert_eq!(config.llm.embedding_model, "qwen3-embedding"); @@ -149,7 +149,7 @@ history_limit = 50 clear_env(); let config = Config::load(&path).unwrap(); - assert_eq!(config.llm.provider, "claude"); + assert_eq!(config.llm.provider, super::ProviderKind::Claude); let cloud = config.llm.cloud.unwrap(); assert_eq!(cloud.model, "claude-sonnet-4-5-20250929"); assert_eq!(cloud.max_tokens, 4096); @@ -1441,7 +1441,7 @@ fn config_load_nonexistent_file_uses_defaults() { let path = std::path::Path::new("/nonexistent/config.toml"); let config = Config::load(path).unwrap(); assert_eq!(config.agent.name, "Zeph"); - assert_eq!(config.llm.provider, "ollama"); + assert_eq!(config.llm.provider, super::ProviderKind::Ollama); } #[test] @@ -1573,7 +1573,7 @@ a2a_seconds = 15 let config = Config::load(&path).unwrap(); assert_eq!(config.agent.name, "FullBot"); - assert_eq!(config.llm.provider, "claude"); + assert_eq!(config.llm.provider, super::ProviderKind::Claude); assert_eq!(config.llm.embedding_model, "nomic"); assert!(config.llm.cloud.is_some()); assert_eq!(config.skills.paths.len(), 2); @@ -1628,7 +1628,7 @@ history_limit = 50 clear_env(); let config = Config::load(&path).unwrap(); - assert_eq!(config.llm.provider, "openai"); + assert_eq!(config.llm.provider, super::ProviderKind::OpenAi); let openai = config.llm.openai.unwrap(); assert_eq!(openai.base_url, "https://api.openai.com/v1"); assert_eq!(openai.model, "gpt-4o"); diff --git a/crates/zeph-core/src/config/types.rs b/crates/zeph-core/src/config/types.rs index 77e8c11d..ac2a43e0 100644 --- a/crates/zeph-core/src/config/types.rs +++ b/crates/zeph-core/src/config/types.rs @@ -55,9 +55,43 @@ pub struct AgentConfig { pub summary_model: Option, } +/// LLM provider backend selector. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProviderKind { + Ollama, + Claude, + OpenAi, + Candle, + Orchestrator, + Compatible, + Router, +} + +impl ProviderKind { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Ollama => "ollama", + Self::Claude => "claude", + Self::OpenAi => "openai", + Self::Candle => "candle", + Self::Orchestrator => "orchestrator", + Self::Compatible => "compatible", + Self::Router => "router", + } + } +} + +impl std::fmt::Display for ProviderKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + #[derive(Debug, Deserialize)] pub struct LlmConfig { - pub provider: String, + pub provider: ProviderKind, pub base_url: String, pub model: String, #[serde(default = "default_embedding_model")] @@ -869,7 +903,7 @@ impl Config { summary_model: None, }, llm: LlmConfig { - provider: "ollama".into(), + provider: ProviderKind::Ollama, base_url: "http://localhost:11434".into(), model: "mistral:7b".into(), embedding_model: default_embedding_model(), diff --git a/crates/zeph-core/src/redact.rs b/crates/zeph-core/src/redact.rs index c67195a3..7ca495c8 100644 --- a/crates/zeph-core/src/redact.rs +++ b/crates/zeph-core/src/redact.rs @@ -51,6 +51,41 @@ pub fn redact_secrets(text: &str) -> Cow<'_, str> { Cow::Owned(result) } +/// Replace absolute filesystem paths with `[PATH]` to prevent information disclosure. +#[must_use] +pub fn sanitize_paths(text: &str) -> Cow<'_, str> { + const PATH_PREFIXES: &[&str] = &["/home/", "/Users/", "/root/", "/tmp/", "/var/"]; + + if !PATH_PREFIXES.iter().any(|p| text.contains(p)) { + return Cow::Borrowed(text); + } + + let bytes = text.as_bytes(); + let len = bytes.len(); + let mut result = String::with_capacity(len); + let mut i = 0; + + while i < len { + if bytes[i].is_ascii_whitespace() { + result.push(bytes[i] as char); + i += 1; + } else { + let start = i; + while i < len && !bytes[i].is_ascii_whitespace() { + i += 1; + } + let token = &text[start..i]; + if PATH_PREFIXES.iter().any(|prefix| token.contains(prefix)) { + result.push_str("[PATH]"); + } else { + result.push_str(token); + } + } + } + + Cow::Owned(result) +} + #[cfg(test)] mod tests { use super::*; @@ -214,4 +249,26 @@ mod tests { let result = redact_secrets(text); assert_eq!(result, "token: [REDACTED]"); } + + #[test] + fn sanitize_home_path() { + let text = "error at /home/user/project/src/main.rs:42"; + let result = sanitize_paths(text); + assert_eq!(result, "error at [PATH]"); + } + + #[test] + fn sanitize_users_path() { + let text = "failed: /Users/dev/code/lib.rs not found"; + let result = sanitize_paths(text); + assert!(result.contains("[PATH]")); + assert!(!result.contains("/Users/")); + } + + #[test] + fn sanitize_no_paths() { + let text = "normal error message"; + let result = sanitize_paths(text); + assert!(matches!(result, Cow::Borrowed(_))); + } } diff --git a/crates/zeph-llm/src/any.rs b/crates/zeph-llm/src/any.rs index c9010d12..59ec0ec6 100644 --- a/crates/zeph-llm/src/any.rs +++ b/crates/zeph-llm/src/any.rs @@ -50,6 +50,17 @@ pub enum AnyProvider { } impl AnyProvider { + /// Return a cloneable closure that calls `embed()` on this provider. + pub fn embed_fn(&self) -> impl Fn(&str) -> crate::provider::EmbedFuture { + let provider = self.clone(); + move |text: &str| -> crate::provider::EmbedFuture { + let owned = text.to_owned(); + let p = provider.clone(); + Box::pin(async move { p.embed(&owned).await }) + } + } + + /// Propagate a status sender to the inner provider (where supported). pub fn set_status_tx(&mut self, tx: StatusTx) { match self { Self::Claude(p) => { diff --git a/crates/zeph-skills/Cargo.toml b/crates/zeph-skills/Cargo.toml index 39f4a042..c9a54855 100644 --- a/crates/zeph-skills/Cargo.toml +++ b/crates/zeph-skills/Cargo.toml @@ -19,7 +19,7 @@ qdrant-client = { workspace = true, optional = true, features = ["serde"] } serde_json = { workspace = true, optional = true } futures.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["sync", "rt"] } +tokio = { workspace = true, features = ["sync", "rt", "time"] } tracing.workspace = true uuid = { workspace = true, optional = true, features = ["v5"] } zeph-llm.workspace = true diff --git a/crates/zeph-skills/src/matcher.rs b/crates/zeph-skills/src/matcher.rs index e6b80fac..9c1c3edc 100644 --- a/crates/zeph-skills/src/matcher.rs +++ b/crates/zeph-skills/src/matcher.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use crate::error::SkillError; use crate::loader::SkillMeta; use futures::stream::{self, StreamExt}; @@ -22,16 +24,20 @@ impl SkillMatcher { let fut = embed_fn(&skill.description); let name = skill.name.clone(); async move { - match fut.await { - Ok(vec) => Some((i, vec)), - Err(e) => { + match tokio::time::timeout(Duration::from_secs(10), fut).await { + Ok(Ok(vec)) => Some((i, vec)), + Ok(Err(e)) => { tracing::warn!("failed to embed skill '{name}': {e:#}"); None } + Err(_) => { + tracing::warn!("embedding timed out for skill '{name}'"); + None + } } } }) - .buffer_unordered(50) + .buffer_unordered(20) .filter_map(|x| async { x }) .collect() .await; @@ -58,12 +64,16 @@ impl SkillMatcher { F: Fn(&str) -> EmbedFuture, { let _ = count; // total skill count, unused for in-memory matcher - let query_vec = match embed_fn(query).await { - Ok(v) => v, - Err(e) => { + let query_vec = match tokio::time::timeout(Duration::from_secs(10), embed_fn(query)).await { + Ok(Ok(v)) => v, + Ok(Err(e)) => { tracing::warn!("failed to embed query: {e:#}"); return Vec::new(); } + Err(_) => { + tracing::warn!("embedding timed out for query"); + return Vec::new(); + } }; let mut scored: Vec<(usize, f32)> = self @@ -142,6 +152,7 @@ impl SkillMatcherBackend { } } +#[inline] #[must_use] pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { if a.len() != b.len() || a.is_empty() { diff --git a/docs/src/architecture/crates.md b/docs/src/architecture/crates.md index e3312b4e..0a07534f 100644 --- a/docs/src/architecture/crates.md +++ b/docs/src/architecture/crates.md @@ -58,6 +58,7 @@ SQLite-backed conversation persistence with Qdrant vector search. Channel implementations for the Zeph agent. +- `AnyChannel` — enum dispatch over all channel variants (Cli, Telegram, Discord, Slack, Tui), used by the binary for runtime channel selection - `ChannelError` — typed error enum (`Telegram`, `NoActiveChat`) replacing prior `anyhow` usage - `CliChannel` — stdin/stdout with immediate streaming output, blocking recv (queue always empty) - `TelegramChannel` — teloxide adapter with MarkdownV2 rendering, streaming via edit-in-place, user whitelisting, inline confirmation keyboards, mpsc-backed message queue with 500ms merge window diff --git a/docs/src/feature-flags.md b/docs/src/feature-flags.md index adf12554..037b5b88 100644 --- a/docs/src/feature-flags.md +++ b/docs/src/feature-flags.md @@ -4,15 +4,17 @@ Zeph uses Cargo feature flags to control optional functionality. Default feature | Feature | Default | Description | |---------|---------|-------------| -| `a2a` | Enabled | [A2A protocol](https://github.com/a2aproject/A2A) client and server for agent-to-agent communication | +| `compatible` | Enabled | `CompatibleProvider` for OpenAI-compatible third-party APIs | | `openai` | Enabled | OpenAI-compatible provider (GPT, Together, Groq, Fireworks, etc.) | -| `mcp` | Enabled | MCP client for external tool servers via stdio/HTTP transport | -| `candle` | Enabled | Local HuggingFace model inference via [candle](https://github.com/huggingface/candle) (GGUF quantized models) | -| `orchestrator` | Enabled | Multi-model routing with task-based classification and fallback chains | -| `self-learning` | Enabled | Skill evolution via failure detection, self-reflection, and LLM-generated improvements | | `qdrant` | Enabled | Qdrant-backed vector storage for skill matching (`zeph-skills`) and MCP tool registry (`zeph-mcp`) | +| `self-learning` | Enabled | Skill evolution via failure detection, self-reflection, and LLM-generated improvements | | `vault-age` | Enabled | Age-encrypted vault backend for file-based secret storage ([age](https://age-encryption.org/)) | -| `index` | Enabled | AST-based code indexing and semantic retrieval via tree-sitter ([guide](guide/code-indexing.md)) | +| `a2a` | Disabled | [A2A protocol](https://github.com/a2aproject/A2A) client and server for agent-to-agent communication | +| `candle` | Disabled | Local HuggingFace model inference via [candle](https://github.com/huggingface/candle) (GGUF quantized models) | +| `index` | Disabled | AST-based code indexing and semantic retrieval via tree-sitter ([guide](guide/code-indexing.md)) | +| `mcp` | Disabled | MCP client for external tool servers via stdio/HTTP transport | +| `orchestrator` | Disabled | Multi-model routing with task-based classification and fallback chains | +| `router` | Disabled | `RouterProvider` for chaining multiple providers with fallback | | `discord` | Disabled | Discord channel adapter with Gateway v10 WebSocket and slash commands ([guide](guide/channels.md#discord-channel)) | | `slack` | Disabled | Slack channel adapter with Events API webhook and HMAC-SHA256 verification ([guide](guide/channels.md#slack-channel)) | | `otel` | Disabled | OpenTelemetry tracing export via OTLP/gRPC ([guide](guide/observability.md)) | @@ -33,9 +35,12 @@ cargo build --release --features tui # with TUI dashboard cargo build --release --features discord # with Discord bot cargo build --release --features slack # with Slack bot cargo build --release --features gateway,daemon,scheduler # with infrastructure components +cargo build --release --features full # all optional features cargo build --release --no-default-features # minimal binary ``` +The `full` feature enables every optional feature except `metal`, `cuda`, and `otel`. + ## zeph-index Language Features When `index` is enabled, tree-sitter grammars are controlled by sub-features on the `zeph-index` crate. All are enabled by default. diff --git a/docs/src/getting-started/configuration.md b/docs/src/getting-started/configuration.md index 64a459fa..ce173347 100644 --- a/docs/src/getting-started/configuration.md +++ b/docs/src/getting-started/configuration.md @@ -17,6 +17,18 @@ ZEPH_CONFIG=/path/to/custom.toml zeph Priority: `--config` > `ZEPH_CONFIG` > `config/default.toml`. +## Validation + +`Config::validate()` runs at startup and rejects out-of-range values: + +| Field | Constraint | +|-------|-----------| +| `memory.history_limit` | <= 10,000 | +| `memory.context_budget_tokens` | <= 1,000,000 (when > 0) | +| `agent.max_tool_iterations` | <= 100 | +| `a2a.rate_limit` | > 0 | +| `gateway.rate_limit` | > 0 | + ## Hot-Reload Zeph watches the config file for changes and applies runtime-safe fields without restart. The file watcher uses 500ms debounce to avoid redundant reloads. @@ -45,7 +57,7 @@ name = "Zeph" max_tool_iterations = 10 # Max tool loop iterations per response (default: 10) [llm] -provider = "ollama" +provider = "ollama" # ollama, claude, openai, candle, compatible, orchestrator, router base_url = "http://localhost:11434" model = "mistral:7b" embedding_model = "qwen3-embedding" # Model for text embeddings @@ -148,7 +160,7 @@ rate_limit = 60 | Variable | Description | |----------|-------------| -| `ZEPH_LLM_PROVIDER` | `ollama`, `claude`, `openai`, `candle`, or `orchestrator` | +| `ZEPH_LLM_PROVIDER` | `ollama`, `claude`, `openai`, `candle`, `compatible`, `orchestrator`, or `router` | | `ZEPH_LLM_BASE_URL` | Ollama API endpoint | | `ZEPH_LLM_MODEL` | Model name for Ollama | | `ZEPH_LLM_EMBEDDING_MODEL` | Embedding model for Ollama (default: `qwen3-embedding`) | diff --git a/docs/src/guide/channels.md b/docs/src/guide/channels.md index ee26da57..de0d02dc 100644 --- a/docs/src/guide/channels.md +++ b/docs/src/guide/channels.md @@ -64,7 +64,7 @@ Restrict bot access to specific Telegram usernames: allowed_users = ["alice", "bob"] ``` -When `allowed_users` is empty, the bot accepts messages from all users. Messages from unauthorized users are silently rejected with a warning log. +The `allowed_users` list **must not be empty**. The Telegram channel refuses to start without at least one allowed username to prevent accidentally exposing the bot to all users. Messages from unauthorized users are silently rejected with a warning log. ### Bot Commands diff --git a/docs/src/security.md b/docs/src/security.md index 7174442a..f18e3b3a 100644 --- a/docs/src/security.md +++ b/docs/src/security.md @@ -113,6 +113,18 @@ LLM responses are scanned for common secret patterns before display: - Secrets replaced with `[REDACTED]` preserving original whitespace formatting - Enabled by default (`security.redact_secrets = true`), applied to both streaming and non-streaming responses +## Config Validation + +`Config::validate()` enforces upper bounds at startup to catch configuration errors early: + +- `memory.history_limit` <= 10,000 +- `memory.context_budget_tokens` <= 1,000,000 (when non-zero) +- `agent.max_tool_iterations` <= 100 +- `a2a.rate_limit` > 0 +- `gateway.rate_limit` > 0 + +The agent exits with an error message if any bound is violated. + ## Timeout Policies Configurable per-operation timeouts prevent hung connections: @@ -133,7 +145,7 @@ a2a_seconds = 30 # A2A remote calls **Safe execution model:** - Commands parsed for blocked patterns, then sandbox-validated, then confirmation-checked - Timeout enforcement (default: 30s, configurable) -- Full errors logged to system, sanitized messages shown to users +- Full errors logged to system; user-facing messages pass through `sanitize_paths()` which replaces absolute filesystem paths (`/home/`, `/Users/`, `/root/`, `/tmp/`, `/var/`) with `[PATH]` to prevent information disclosure - Audit trail for all tool executions (when enabled) ## Container Security diff --git a/src/main.rs b/src/main.rs index 5f27b523..7e40407a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,8 +11,9 @@ use zeph_channels::discord::DiscordChannel; use zeph_channels::slack::SlackChannel; use zeph_channels::telegram::TelegramChannel; use zeph_core::agent::Agent; +#[cfg(feature = "tui")] use zeph_core::channel::{Channel, ChannelError, ChannelMessage}; -use zeph_core::config::Config; +use zeph_core::config::{Config, ProviderKind}; use zeph_core::config_watcher::ConfigWatcher; use zeph_core::cost::CostTracker; #[cfg(feature = "vault-age")] @@ -46,78 +47,56 @@ use zeph_tools::{CompositeExecutor, FileExecutor, ShellExecutor, WebScrapeExecut #[cfg(feature = "tui")] use zeph_tui::{App, EventReader, TuiChannel}; -/// Enum dispatch for runtime channel selection, following the `AnyProvider` pattern. +use zeph_channels::AnyChannel; + +#[cfg(feature = "tui")] #[derive(Debug)] -enum AnyChannel { - Cli(CliChannel), - Telegram(TelegramChannel), - #[cfg(feature = "discord")] - Discord(DiscordChannel), - #[cfg(feature = "slack")] - Slack(SlackChannel), - #[cfg(feature = "tui")] +enum AppChannel { + Standard(AnyChannel), Tui(TuiChannel), } -macro_rules! dispatch_channel { +#[cfg(feature = "tui")] +macro_rules! dispatch_app_channel { ($self:expr, $method:ident $(, $arg:expr)*) => { match $self { - Self::Cli(c) => c.$method($($arg),*).await, - Self::Telegram(c) => c.$method($($arg),*).await, - #[cfg(feature = "discord")] - Self::Discord(c) => c.$method($($arg),*).await, - #[cfg(feature = "slack")] - Self::Slack(c) => c.$method($($arg),*).await, - #[cfg(feature = "tui")] - Self::Tui(c) => c.$method($($arg),*).await, + AppChannel::Standard(c) => c.$method($($arg),*).await, + AppChannel::Tui(c) => c.$method($($arg),*).await, } }; } -impl Channel for AnyChannel { +#[cfg(feature = "tui")] +impl Channel for AppChannel { async fn recv(&mut self) -> Result, ChannelError> { - dispatch_channel!(self, recv) + dispatch_app_channel!(self, recv) } - async fn send(&mut self, text: &str) -> Result<(), ChannelError> { - dispatch_channel!(self, send, text) + dispatch_app_channel!(self, send, text) } - async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> { - dispatch_channel!(self, send_chunk, chunk) + dispatch_app_channel!(self, send_chunk, chunk) } - async fn flush_chunks(&mut self) -> Result<(), ChannelError> { - dispatch_channel!(self, flush_chunks) + dispatch_app_channel!(self, flush_chunks) } - async fn send_typing(&mut self) -> Result<(), ChannelError> { - dispatch_channel!(self, send_typing) + dispatch_app_channel!(self, send_typing) } - async fn confirm(&mut self, prompt: &str) -> Result { - dispatch_channel!(self, confirm, prompt) + dispatch_app_channel!(self, confirm, prompt) } - fn try_recv(&mut self) -> Option { match self { - Self::Cli(c) => c.try_recv(), - Self::Telegram(c) => c.try_recv(), - #[cfg(feature = "discord")] - Self::Discord(c) => c.try_recv(), - #[cfg(feature = "slack")] - Self::Slack(c) => c.try_recv(), - #[cfg(feature = "tui")] + Self::Standard(c) => c.try_recv(), Self::Tui(c) => c.try_recv(), } } - async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> { - dispatch_channel!(self, send_status, text) + dispatch_app_channel!(self, send_status, text) } - async fn send_queue_count(&mut self, count: usize) -> Result<(), ChannelError> { - dispatch_channel!(self, send_queue_count, count) + dispatch_app_channel!(self, send_queue_count, count) } } @@ -148,6 +127,7 @@ async fn main() -> anyhow::Result<()> { let config_path = resolve_config_path(); let mut config = Config::load(&config_path)?; + config.validate()?; let vault_args = parse_vault_args(&config); let vault: Box = match vault_args.backend.as_str() { @@ -231,7 +211,11 @@ async fn main() -> anyhow::Result<()> { #[cfg(not(feature = "tui"))] let channel = create_channel(&config).await?; - if matches!(channel, AnyChannel::Cli(_)) { + #[cfg(feature = "tui")] + let is_cli = matches!(channel, AppChannel::Standard(AnyChannel::Cli(_))); + #[cfg(not(feature = "tui"))] + let is_cli = matches!(channel, AnyChannel::Cli(_)); + if is_cli { println!("zeph v{}", env!("CARGO_PKG_VERSION")); } @@ -702,12 +686,7 @@ async fn create_skill_matcher( memory: &SemanticMemory, embedding_model: &str, ) -> Option { - let p = provider.clone(); - let embed_fn = move |text: &str| -> zeph_skills::matcher::EmbedFuture { - let owned = text.to_owned(); - let p = p.clone(); - Box::pin(async move { p.embed(&owned).await }) - }; + let embed_fn = provider.embed_fn(); #[cfg(feature = "qdrant")] if config.memory.semantic.enabled && memory.has_qdrant() { @@ -730,9 +709,9 @@ async fn create_skill_matcher( } fn effective_embedding_model(config: &Config) -> String { - match config.llm.provider.as_str() { + match config.llm.provider { #[cfg(feature = "openai")] - "openai" => { + ProviderKind::OpenAi => { if let Some(m) = config .llm .openai @@ -743,7 +722,7 @@ fn effective_embedding_model(config: &Config) -> String { } } #[cfg(feature = "orchestrator")] - "orchestrator" => { + ProviderKind::Orchestrator => { if let Some(orch) = &config.llm.orchestrator && let Some(pcfg) = orch.providers.get(&orch.embed) { @@ -759,75 +738,31 @@ fn effective_embedding_model(config: &Config) -> String { } } } - other => { + ProviderKind::Compatible => { if let Some(entries) = &config.llm.compatible - && let Some(entry) = entries.iter().find(|e| e.name == other) + && let Some(entry) = entries.first() && let Some(ref m) = entry.embedding_model { return m.clone(); } } + _ => {} } config.llm.embedding_model.clone() } #[allow(clippy::too_many_lines)] fn create_provider(config: &Config) -> anyhow::Result { - match config.llm.provider.as_str() { - "ollama" => { - let provider = OllamaProvider::new( - &config.llm.base_url, - config.llm.model.clone(), - config.llm.embedding_model.clone(), - ); - Ok(AnyProvider::Ollama(provider)) - } - "claude" => { - let cloud = config - .llm - .cloud - .as_ref() - .context("llm.cloud config section required for Claude provider")?; - - let api_key = config - .secrets - .claude_api_key - .as_ref() - .context("ZEPH_CLAUDE_API_KEY not found in vault")? - .expose() - .to_owned(); - - let provider = ClaudeProvider::new(api_key, cloud.model.clone(), cloud.max_tokens); - Ok(AnyProvider::Claude(provider)) + match config.llm.provider { + ProviderKind::Ollama | ProviderKind::Claude => { + create_named_provider(config.llm.provider.as_str(), config) } #[cfg(feature = "openai")] - "openai" => { - let openai_cfg = config - .llm - .openai - .as_ref() - .context("llm.openai config section required for OpenAI provider")?; - - let api_key = config - .secrets - .openai_api_key - .as_ref() - .context("ZEPH_OPENAI_API_KEY not found in vault")? - .expose() - .to_owned(); - - let provider = OpenAiProvider::new( - api_key, - openai_cfg.base_url.clone(), - openai_cfg.model.clone(), - openai_cfg.max_tokens, - openai_cfg.embedding_model.clone(), - openai_cfg.reasoning_effort.clone(), - ); - Ok(AnyProvider::OpenAi(provider)) - } + ProviderKind::OpenAi => create_named_provider("openai", config), + #[cfg(feature = "compatible")] + ProviderKind::Compatible => create_named_provider("compatible", config), #[cfg(feature = "candle")] - "candle" => { + ProviderKind::Candle => { let candle_cfg = config .llm .candle @@ -869,12 +804,12 @@ fn create_provider(config: &Config) -> anyhow::Result { Ok(AnyProvider::Candle(provider)) } #[cfg(feature = "orchestrator")] - "orchestrator" => { + ProviderKind::Orchestrator => { let orch = build_orchestrator(config)?; Ok(AnyProvider::Orchestrator(Box::new(orch))) } #[cfg(feature = "router")] - "router" => { + ProviderKind::Router => { let router_cfg = config .llm .router @@ -893,40 +828,11 @@ fn create_provider(config: &Config) -> anyhow::Result { providers, )))) } - other => { - #[cfg(feature = "compatible")] - if let Some(entries) = &config.llm.compatible - && let Some(entry) = entries.iter().find(|e| e.name == other) - { - let api_key = config - .secrets - .compatible_api_keys - .get(&entry.name) - .with_context(|| { - format!( - "ZEPH_COMPATIBLE_{}_API_KEY not found in vault", - entry.name.to_uppercase() - ) - })? - .expose() - .to_owned(); - - let provider = CompatibleProvider::new( - entry.name.clone(), - api_key, - entry.base_url.clone(), - entry.model.clone(), - entry.max_tokens, - entry.embedding_model.clone(), - ); - return Ok(AnyProvider::Compatible(provider)); - } - bail!("unknown LLM provider: {other}") - } + #[allow(unreachable_patterns)] + other => bail!("LLM provider {other} not available (feature not enabled)"), } } -#[cfg(feature = "router")] fn create_named_provider(name: &str, config: &Config) -> anyhow::Result { match name { "ollama" => { @@ -942,12 +848,12 @@ fn create_named_provider(name: &str, config: &Config) -> anyhow::Result anyhow::Result anyhow::Result { #[cfg(feature = "compatible")] - if let Some(entries) = &config.llm.compatible - && let Some(entry) = entries.iter().find(|e| e.name == other) - { - let api_key = config - .secrets - .compatible_api_keys - .get(&entry.name) - .with_context(|| { - format!( - "ZEPH_COMPATIBLE_{}_API_KEY required for {} in router chain", - entry.name.to_uppercase(), - entry.name - ) - })? - .expose() - .to_owned(); - return Ok(AnyProvider::Compatible(CompatibleProvider::new( - entry.name.clone(), - api_key, - entry.base_url.clone(), - entry.model.clone(), - entry.max_tokens, - entry.embedding_model.clone(), - ))); + if let Some(entries) = &config.llm.compatible { + let entry = if other == "compatible" { + entries.first() + } else { + entries.iter().find(|e| e.name == other) + }; + if let Some(entry) = entry { + let api_key = config + .secrets + .compatible_api_keys + .get(&entry.name) + .with_context(|| { + format!( + "ZEPH_COMPATIBLE_{}_API_KEY required for {}", + entry.name.to_uppercase(), + entry.name + ) + })? + .expose() + .to_owned(); + return Ok(AnyProvider::Compatible(CompatibleProvider::new( + entry.name.clone(), + api_key, + entry.base_url.clone(), + entry.model.clone(), + entry.max_tokens, + entry.embedding_model.clone(), + ))); + } } - bail!("unknown provider in router chain: {other}") + bail!("unknown provider: {other}") } } } @@ -1161,12 +1072,7 @@ async fn create_mcp_registry( } match zeph_mcp::McpToolRegistry::new(&config.memory.qdrant_url) { Ok(mut reg) => { - let p = provider.clone(); - let embed_fn = move |text: &str| -> zeph_mcp::registry::EmbedFuture { - let owned = text.to_owned(); - let p = p.clone(); - Box::pin(async move { p.embed(&owned).await }) - }; + let embed_fn = provider.embed_fn(); if let Err(e) = reg.sync(mcp_tools, embedding_model, &embed_fn).await { tracing::warn!("MCP tool embedding sync failed: {e:#}"); } @@ -1401,7 +1307,7 @@ fn is_tui_requested() -> bool { #[cfg(feature = "tui")] async fn create_channel_with_tui( config: &Config, -) -> anyhow::Result<(AnyChannel, Option)> { +) -> anyhow::Result<(AppChannel, Option)> { if is_tui_requested() { let (user_tx, user_rx) = tokio::sync::mpsc::channel(32); let (agent_tx, agent_rx) = tokio::sync::mpsc::channel(256); @@ -1412,10 +1318,10 @@ async fn create_channel_with_tui( agent_tx: agent_tx_clone, agent_rx, }; - return Ok((AnyChannel::Tui(channel), Some(handle))); + return Ok((AppChannel::Tui(channel), Some(handle))); } let channel = create_channel_inner(config).await?; - Ok((channel, None)) + Ok((AppChannel::Standard(channel), None)) } #[cfg_attr(feature = "tui", allow(dead_code))] @@ -1650,7 +1556,7 @@ mod tests { #[test] fn config_loading_nonexistent_uses_defaults() { let config = Config::load(Path::new("/does/not/exist.toml")).unwrap(); - assert_eq!(config.llm.provider, "ollama"); + assert_eq!(config.llm.provider, ProviderKind::Ollama); assert_eq!(config.agent.name, "Zeph"); } @@ -1662,24 +1568,10 @@ mod tests { assert_eq!(provider.name(), "ollama"); } - #[test] - fn create_provider_unknown_errors() { - let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "unknown_provider".into(); - let result = create_provider(&config); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("unknown LLM provider") - ); - } - #[test] fn create_provider_claude_without_cloud_config_errors() { let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "claude".into(); + config.llm.provider = ProviderKind::Claude; config.llm.cloud = None; let result = create_provider(&config); assert!(result.is_err()); @@ -1772,7 +1664,7 @@ mod tests { #[test] fn create_provider_candle_without_config_errors() { let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "candle".into(); + config.llm.provider = ProviderKind::Candle; config.llm.candle = None; let result = create_provider(&config); assert!(result.is_err()); @@ -1788,7 +1680,7 @@ mod tests { #[test] fn create_provider_orchestrator_without_config_errors() { let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "orchestrator".into(); + config.llm.provider = ProviderKind::Orchestrator; config.llm.orchestrator = None; let result = create_provider(&config); assert!(result.is_err()); @@ -1807,7 +1699,7 @@ mod tests { use zeph_core::config::OrchestratorProviderConfig; let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "orchestrator".into(); + config.llm.provider = ProviderKind::Orchestrator; let mut providers = HashMap::new(); providers.insert( @@ -1844,7 +1736,7 @@ mod tests { use zeph_core::config::OrchestratorProviderConfig; let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "orchestrator".into(); + config.llm.provider = ProviderKind::Orchestrator; config.llm.cloud = None; let mut providers = HashMap::new(); @@ -1882,7 +1774,7 @@ mod tests { use zeph_core::config::OrchestratorProviderConfig; let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "orchestrator".into(); + config.llm.provider = ProviderKind::Orchestrator; config.llm.candle = None; let mut providers = HashMap::new(); @@ -2000,7 +1892,7 @@ mod tests { #[test] fn create_provider_claude_without_api_key_errors() { let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "claude".into(); + config.llm.provider = ProviderKind::Claude; config.llm.cloud = Some(zeph_core::config::CloudLlmConfig { model: "claude-3-opus".into(), max_tokens: 4096, @@ -2024,7 +1916,7 @@ mod tests { use zeph_core::config::OrchestratorProviderConfig; let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "orchestrator".into(); + config.llm.provider = ProviderKind::Orchestrator; config.llm.cloud = Some(zeph_core::config::CloudLlmConfig { model: "claude-3".into(), max_tokens: 4096, @@ -2112,7 +2004,7 @@ mod tests { let mut config = Config::load(Path::new("/nonexistent")).unwrap(); config.telegram = Some(zeph_core::config::TelegramConfig { token: Some("test_token".to_string()), - allowed_users: vec![], + allowed_users: vec!["testuser".to_string()], }); let channel = create_channel(&config).await.unwrap(); assert!(matches!(channel, AnyChannel::Telegram(_))); @@ -2152,14 +2044,20 @@ mod tests { } #[tokio::test] - async fn create_channel_telegram_with_empty_allowed_users() { + async fn create_channel_telegram_with_empty_allowed_users_errors() { let mut config = Config::load(Path::new("/nonexistent")).unwrap(); config.telegram = Some(zeph_core::config::TelegramConfig { token: Some("test_token2".to_string()), allowed_users: vec![], }); - let channel = create_channel(&config).await.unwrap(); - assert!(matches!(channel, AnyChannel::Telegram(_))); + let result = create_channel(&config).await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("allowed_users must not be empty") + ); } #[cfg(feature = "candle")] @@ -2230,7 +2128,7 @@ mod tests { use zeph_core::config::OrchestratorProviderConfig; let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "orchestrator".into(); + config.llm.provider = ProviderKind::Orchestrator; config.llm.candle = Some(zeph_core::config::CandleConfig { source: "local".into(), local_path: "/tmp/model.gguf".into(), @@ -2278,7 +2176,7 @@ mod tests { use zeph_core::config::OrchestratorProviderConfig; let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "orchestrator".into(); + config.llm.provider = ProviderKind::Orchestrator; let mut providers = HashMap::new(); providers.insert( @@ -2309,7 +2207,7 @@ mod tests { use zeph_core::config::OrchestratorProviderConfig; let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "orchestrator".into(); + config.llm.provider = ProviderKind::Orchestrator; let mut providers = HashMap::new(); providers.insert( @@ -2341,7 +2239,7 @@ mod tests { #[test] fn create_provider_openai_missing_config_errors() { let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "openai".into(); + config.llm.provider = ProviderKind::OpenAi; config.llm.openai = None; let result = create_provider(&config); assert!(result.is_err()); @@ -2363,7 +2261,7 @@ mod tests { #[test] fn effective_embedding_model_uses_openai_when_set() { let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "openai".into(); + config.llm.provider = ProviderKind::OpenAi; config.llm.openai = Some(zeph_core::config::OpenAiConfig { base_url: "https://api.openai.com/v1".into(), model: "gpt-5.2".into(), @@ -2378,7 +2276,7 @@ mod tests { #[test] fn effective_embedding_model_falls_back_when_openai_embed_missing() { let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "openai".into(); + config.llm.provider = ProviderKind::OpenAi; config.llm.openai = Some(zeph_core::config::OpenAiConfig { base_url: "https://api.openai.com/v1".into(), model: "gpt-5.2".into(), @@ -2393,7 +2291,7 @@ mod tests { #[test] fn create_provider_openai_missing_api_key_errors() { let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = "openai".into(); + config.llm.provider = ProviderKind::OpenAi; config.llm.openai = Some(zeph_core::config::OpenAiConfig { base_url: "https://api.openai.com/v1".into(), model: "gpt-4o".into(), diff --git a/tests/integration.rs b/tests/integration.rs index b76eafd2..93341ed9 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex}; use zeph_core::agent::Agent; use zeph_core::channel::{Channel, ChannelError, ChannelMessage}; -use zeph_core::config::{Config, SecurityConfig, TimeoutConfig}; +use zeph_core::config::{Config, ProviderKind, SecurityConfig, TimeoutConfig}; use zeph_llm::error::LlmError; use zeph_llm::provider::{LlmProvider, Message}; use zeph_memory::semantic::SemanticMemory; @@ -425,7 +425,7 @@ fn config_defaults_and_env_overrides() { clear_env(); let config = Config::load(Path::new("/nonexistent/config.toml")).unwrap(); - assert_eq!(config.llm.provider, "ollama"); + assert_eq!(config.llm.provider, ProviderKind::Ollama); assert_eq!(config.llm.base_url, "http://localhost:11434"); assert_eq!(config.llm.model, "mistral:7b"); assert_eq!(config.agent.name, "Zeph");