From b626a89b7251fc8a5035f69b08ef3d8cededaf32 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Tue, 17 Feb 2026 02:11:12 +0100 Subject: [PATCH 1/3] feat: architecture improvements, performance optimizations, security hardening (M24) Extract ProviderKind enum replacing stringly-typed provider selection. Group Agent loose fields into RuntimeConfig struct. Consolidate provider construction via shared create_named_provider. Add AnyProvider::embed_fn() eliminating duplicate closures. Move AnyChannel to zeph-channels crate. Trim default features to minimal set (qdrant, self-learning, vault-age). Performance: String::with_capacity in context loops, buffer_unordered(20), embedding timeout wrappers, inline cosine_similarity. Security: reject empty Telegram allowed_users, Config::validate() with bounds checking, sanitize_paths() for error message path disclosure. Resolves #392, #394, #395, #396, #397, #399, #401, #403, #404, #405, #408, #409, #412, #415, #416 --- .github/workflows/ci.yml | 8 +- Cargo.lock | 237 ++++++++--------- Cargo.toml | 3 +- crates/zeph-channels/src/any.rs | 77 ++++++ crates/zeph-channels/src/lib.rs | 2 + crates/zeph-channels/src/telegram.rs | 7 + crates/zeph-core/src/agent/context.rs | 7 +- crates/zeph-core/src/agent/mod.rs | 58 +++-- crates/zeph-core/src/agent/streaming.rs | 23 +- crates/zeph-core/src/config/env.rs | 6 +- crates/zeph-core/src/config/mod.rs | 31 +++ crates/zeph-core/src/config/tests.rs | 10 +- crates/zeph-core/src/config/types.rs | 38 ++- crates/zeph-core/src/redact.rs | 57 +++++ crates/zeph-llm/src/any.rs | 11 + crates/zeph-skills/Cargo.toml | 2 +- crates/zeph-skills/src/matcher.rs | 25 +- src/main.rs | 322 ++++++++---------------- 18 files changed, 526 insertions(+), 398 deletions(-) create mode 100644 crates/zeph-channels/src/any.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 908b7b9d..328786bb 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,7 @@ 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" @@ -86,7 +86,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 +106,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/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/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/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(), From 95a70d8a9fca86fa393e16c6f80cc514ef9fb82a Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Tue, 17 Feb 2026 02:19:00 +0100 Subject: [PATCH 2/3] fix: update integration test for ProviderKind enum Replace string comparison with ProviderKind::Ollama in config defaults test. --- tests/integration.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"); From d19ceff7b104b35eb91ba4d5fdfae6e414e6966b Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Tue, 17 Feb 2026 02:28:57 +0100 Subject: [PATCH 3/3] docs: update documentation, changelog, and readme for M24 Update feature-flags, configuration, channels, architecture, and security docs to reflect ProviderKind enum, minimal default features, Telegram auth guard, config validation, and path sanitization. Add doc tests step to CI workflow. Update CHANGELOG.md with Unreleased section for M24 changes. Update README.md with new feature flags and architecture notes. --- .github/workflows/ci.yml | 5 +++ CHANGELOG.md | 28 ++++++++++++++ README.md | 45 +++++++++++++---------- docs/src/architecture/crates.md | 1 + docs/src/feature-flags.md | 17 ++++++--- docs/src/getting-started/configuration.md | 16 +++++++- docs/src/guide/channels.md | 2 +- docs/src/security.md | 14 ++++++- 8 files changed, 99 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 328786bb..3189bd87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,11 @@ jobs: 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" integration: name: Integration Tests 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/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/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