diff --git a/Cargo.lock b/Cargo.lock index 3b60dc4579..40f33e13b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,19 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if 1.0.4", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -2174,7 +2187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -3006,8 +3019,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -3606,7 +3619,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4498,8 +4511,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "mesh-llm-client", @@ -4508,8 +4521,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4519,13 +4532,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-llm-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -4556,8 +4569,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4572,8 +4585,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -4582,8 +4595,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "clap", @@ -4594,8 +4607,8 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "cc", @@ -4607,8 +4620,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -4616,16 +4629,16 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "mesh-llm-native-runtime", ] [[package]] name = "mesh-llm-host-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "argon2", @@ -4647,7 +4660,6 @@ dependencies = [ "http", "http-body-util", "httparse", - "if-addrs", "iroh", "json5", "keyring", @@ -4695,6 +4707,7 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "skippy-coordinator", + "skippy-ffi", "skippy-protocol", "skippy-runtime", "skippy-server", @@ -4717,8 +4730,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "argon2", "base64", @@ -4739,8 +4752,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "serde", @@ -4750,8 +4763,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-types", @@ -4764,8 +4777,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -4781,8 +4794,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4792,6 +4805,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "sha2 0.10.9", "tar", "tempfile", "zip", @@ -4799,8 +4813,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "hex", @@ -4812,16 +4826,16 @@ dependencies = [ [[package]] name = "mesh-llm-routing" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4843,8 +4857,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4858,8 +4872,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4869,8 +4883,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "chrono", @@ -4892,8 +4906,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "serde", @@ -4903,13 +4917,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-mixture-of-agents" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5035,8 +5049,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5046,8 +5060,8 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5064,8 +5078,8 @@ dependencies = [ [[package]] name = "model-package" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "bytes", @@ -5084,16 +5098,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "model-artifact", @@ -5812,8 +5826,8 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openai-frontend" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "axum", @@ -7365,9 +7379,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", "base64", @@ -7398,9 +7412,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -8150,8 +8164,8 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "skippy-cache" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "blake3", @@ -8160,29 +8174,29 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "libloading", ] [[package]] name = "skippy-metrics" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "skippy-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "prost", "prost-build", @@ -8192,8 +8206,8 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "libc", @@ -8206,9 +8220,10 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ + "ahash", "anyhow", "async-trait", "axum", @@ -8234,8 +8249,8 @@ dependencies = [ [[package]] name = "skippy-topology" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -10462,8 +10477,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.62.2", - "windows-core 0.62.2", + "windows 0.61.3", + "windows-core 0.61.2", ] [[package]] diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8a698954a0..b1626d3f75 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -211,6 +211,56 @@ pub struct AcpClient { /// deltas. Both goose and buzz-agent emit this notification; goose gates /// on client capability advertisement, buzz-agent emits unconditionally. goose_usage: UsageTracker, + /// Plain assistant text streamed during the current turn, and whether the + /// agent published a reply itself. Small local models routinely finish a + /// multi-step turn by writing the answer as prose instead of calling + /// `send_message`; ACP treats streamed text as observability only, so that + /// answer is dropped. The pool's mesh-gated delivery fallback consumes + /// these via [`take_undelivered_turn_text`](Self::take_undelivered_turn_text). + turn_text: String, + /// Whether a message-publish tool call was observed during this turn. + turn_published: bool, +} + +/// Maximum assistant text retained per turn for the delivery fallback. +/// +/// Bounded so a rogue agent streaming forever cannot grow the buffer without +/// limit; a chat reply that exceeds this is truncated rather than dropped. +const MAX_TURN_TEXT_BYTES: usize = 8 * 1024; + +/// Whether an ACP `tool_call` update represents the agent publishing a message. +/// +/// Matches both shapes an agent can use: the first-class `send_message` tool +/// (any `dev__`/server prefix) and a shell command that runs the CLI directly. +fn is_message_publish(title: &str, raw_input: &str) -> bool { + title.ends_with("send_message") + || raw_input.contains("messages send") + || raw_input.contains("social publish") +} + +/// Whether text is a bare acknowledgement not worth publishing as a reply. +/// +/// Guards the fallback against posting filler like "OK" or "Done." when the +/// agent had nothing substantive to say. +fn is_bare_acknowledgement(text: &str) -> bool { + let normalized: String = text + .chars() + .filter(|c| c.is_alphanumeric() || c.is_whitespace()) + .collect::() + .trim() + .to_ascii_lowercase(); + matches!( + normalized.as_str(), + "" | "ok" + | "okay" + | "done" + | "sure" + | "got it" + | "understood" + | "acknowledged" + | "will do" + | "thanks" + ) } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -550,6 +600,8 @@ impl AcpClient { steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), + turn_text: String::new(), + turn_published: false, }) } @@ -760,6 +812,11 @@ impl AcpClient { // misattributed to this turn. self.goose_usage.begin_turn(session_id); + // Reset per-turn delivery-fallback state alongside usage: text streamed + // by a previous turn must never be republished by this one. + self.turn_text.clear(); + self.turn_published = false; + self.last_prompt_id = Some(self.next_id); let id = self.next_id; self.next_id += 1; @@ -864,6 +921,23 @@ impl AcpClient { self.goose_usage.take() } + /// Take the turn's assistant text if the agent never published a reply. + /// + /// Returns `None` when the agent published via `send_message` (or the CLI) + /// itself, or when the only text was a bare acknowledgement. Consuming + /// clears the buffer so the same text can never be posted twice. + pub fn take_undelivered_turn_text(&mut self) -> Option { + let text = std::mem::take(&mut self.turn_text); + if self.turn_published { + return None; + } + let trimmed = text.trim(); + if trimmed.is_empty() || is_bare_acknowledgement(trimmed) { + return None; + } + Some(trimmed.to_string()) + } + /// Install a per-turn steer request channel for goose-native /// non-cancelling mid-turn delivery. /// @@ -1715,6 +1789,15 @@ impl AcpClient { "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { tracing::info!(target: "acp::stream", "{text}"); + // Retain for the mesh-gated delivery fallback (bounded). + let remaining = MAX_TURN_TEXT_BYTES.saturating_sub(self.turn_text.len()); + if remaining > 0 { + let mut end = text.len().min(remaining); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + self.turn_text.push_str(&text[..end]); + } } false } @@ -1728,6 +1811,15 @@ impl AcpClient { .and_then(|v| v.as_str()) .unwrap_or("unknown"); tracing::info!(target: "acp::tool", "tool_call: {title} ({kind})"); + if !self.turn_published { + let raw_input = update + .get("rawInput") + .map(|v| v.to_string()) + .unwrap_or_default(); + if is_message_publish(title, &raw_input) { + self.turn_published = true; + } + } true } "tool_call_update" => { @@ -2223,6 +2315,43 @@ fn configure_no_window(cmd: &mut tokio::process::Command) { mod tests { use super::*; + #[test] + fn publish_detection_matches_tool_and_shell_shapes() { + // First-class tool, with the MCP server prefix the agent reports. + assert!(is_message_publish("dev__send_message", "{}")); + assert!(is_message_publish("send_message", "{}")); + // Shell shapes: the CLI invoked directly. + assert!(is_message_publish( + "dev__shell", + r#"{"command":"buzz messages send --channel c --content hi"}"# + )); + assert!(is_message_publish( + "dev__shell", + r#"{"command":"buzz social publish --content hi"}"# + )); + // Unrelated tools must not suppress the fallback. + assert!(!is_message_publish("dev__todo", "{}")); + assert!(!is_message_publish("dev__shell", r#"{"command":"ls -la"}"#)); + // `read_file` ends in neither name and must not match. + assert!(!is_message_publish("dev__read_file", r#"{"path":"a"}"#)); + } + + #[test] + fn bare_acknowledgements_are_not_worth_publishing() { + for text in ["OK", "ok.", "Done!", " Sure ", "Got it", "will do", ""] { + assert!( + is_bare_acknowledgement(text), + "expected {text:?} to be a bare ack" + ); + } + for text in ["12", "The capital is Paris.", "OK, the answer is 12"] { + assert!( + !is_bare_acknowledgement(text), + "expected {text:?} to be substantive" + ); + } + } + #[test] fn stop_reason_parses_all_known_values() { assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn)); diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index dab61be30a..2f8208e330 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -409,6 +409,27 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_NO_BASE_PROMPT")] pub no_base_prompt: bool, + /// Offer the first-class `send_message` reply tool to the agent. + /// + /// Off by default: adding a tool changes the toolset — and so the prompt — + /// that every agent sees, and capable cloud models already publish their + /// own replies by composing a `buzz messages send` shell command. Small + /// local models served over shared compute deliver far more reliably with + /// a typed tool, so the desktop shared-compute preset opts in. + #[arg(long, env = "BUZZ_ACP_SEND_MESSAGE_TOOL")] + pub send_message_tool: bool, + + /// Publish the agent's plain reply text when it ends a turn without calling + /// `send_message`. + /// + /// Off by default: capable models always publish their own replies, and + /// posting streamed text unconditionally would double-post. Small local + /// models served over shared compute reliably finish multi-step turns by + /// writing the answer as prose instead, so the desktop shared-compute + /// preset opts in. + #[arg(long, env = "BUZZ_ACP_DELIVER_PLAIN_REPLIES")] + pub deliver_plain_replies: bool, + /// Path to a custom base prompt file. Overrides the compiled-in default. /// Mutually exclusive with --no-base-prompt. #[arg( @@ -561,6 +582,12 @@ pub struct Config { /// `from_cli()`. `None` when using the compiled-in default or when /// `--no-base-prompt` is set. pub base_prompt_content: Option, + /// Offer the first-class `send_message` reply tool. Opt-in; see the CLI + /// flag for rationale. + pub send_message_tool: bool, + /// Publish the agent's plain reply text when a turn ends without a + /// `send_message` call. Opt-in; see the CLI flag for rationale. + pub deliver_plain_replies: bool, } /// Maximum length, in characters, of a session title sent to the adapter. @@ -1102,6 +1129,8 @@ impl Config { agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, base_prompt_content, + send_message_tool: args.send_message_tool, + deliver_plain_replies: args.deliver_plain_replies, }; Ok(config) @@ -1471,6 +1500,8 @@ mod tests { lazy_pool: false, agent_owner: None, no_base_prompt: false, + send_message_tool: false, + deliver_plain_replies: false, base_prompt_content: None, } } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d63f720c65..8970896585 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1557,6 +1557,7 @@ async fn tokio_main() -> Result<()> { .as_deref() .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), memory_enabled: config.memory_enabled, + deliver_plain_replies: config.deliver_plain_replies, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), }); @@ -4212,6 +4213,19 @@ fn build_mcp_servers(config: &Config) -> Vec { }); } } + // Offer the first-class `send_message` reply tool only where it is + // needed. Small local models served over shared compute deliver + // replies far more reliably with a typed tool than by composing a + // `buzz messages send` shell command; capable cloud models already + // publish reliably, and adding a tool changes the toolset every + // agent sees, so this stays scoped to the preset that measured a + // benefit. dev-mcp omits the tool entirely when this is unset. + if config.send_message_tool { + env.push(EnvVar { + name: "BUZZ_MCP_SEND_MESSAGE_TOOL".into(), + value: "1".into(), + }); + } env }, }] @@ -5018,6 +5032,8 @@ mod build_mcp_servers_tests { lazy_pool: false, agent_owner: None, no_base_prompt: false, + send_message_tool: false, + deliver_plain_replies: false, base_prompt_content: None, } } @@ -5239,6 +5255,8 @@ mod error_outcome_emission_tests { lazy_pool: false, agent_owner: None, no_base_prompt: false, + send_message_tool: false, + deliver_plain_replies: false, base_prompt_content: None, } } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 038f8a714c..96f3317995 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -543,6 +543,11 @@ pub struct PromptContext { /// `[Agent Memory — core]` section. On by default; disabled via /// `--no-memory` / `BUZZ_ACP_NO_MEMORY`. pub memory_enabled: bool, + /// Whether to publish the agent's plain reply text when a turn ends without + /// a `send_message` call. Off by default; the desktop shared-compute preset + /// opts in because small local models routinely finish a multi-step turn by + /// writing the answer as prose instead of calling the tool. + pub deliver_plain_replies: bool, /// Harness identity string for NIP-AM `harness` field. Derived from the /// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`). pub harness_name: String, @@ -1362,6 +1367,14 @@ pub async fn run_prompt_task( .as_ref() .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) .unwrap_or_default(); + // Capture the reply target now: `batch` is moved into the requeue/outcome + // arms below, but the plain-reply fallback runs after the turn completes. + // Heartbeats have no batch and therefore no fallback target. + let plain_reply_target = if ctx.deliver_plain_replies { + batch.as_ref().and_then(plain_reply_target_for) + } else { + None + }; agent.acp.observe( "turn_started", serde_json::json!({ @@ -2112,6 +2125,31 @@ pub async fn run_prompt_task( agent.state.invalidate(&source); } + // Deliver the turn's plain text when the agent ended the turn + // without publishing a reply itself. Gated on EndTurn only: + // MaxTokens / MaxTurnRequests mean the turn was truncated, so the + // text is not a deliberate answer. `take_*` clears the buffer, so + // this can never post the same text twice. + if matches!(stop_reason, StopReason::EndTurn) { + if let Some(target) = plain_reply_target.as_ref() { + if let Some(text) = agent.acp.take_undelivered_turn_text() { + tracing::warn!( + target: "pool::prompt", + channel = %target.channel_id, + "agent ended turn without publishing; delivering its plain text as a channel reply" + ); + post_threaded_message( + &ctx.rest_client, + target.channel_id, + &target.thread_tags, + &text, + "plain-reply delivery", + ) + .await; + } + } + } + let core_stop = acp_stop_to_core(&stop_reason); let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( @@ -3584,6 +3622,34 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str } } +/// Where a plain-reply fallback should post, captured before `batch` is moved. +#[derive(Debug, Clone)] +pub(crate) struct PlainReplyTarget { + channel_id: Uuid, + thread_tags: ThreadTags, +} + +/// Derive the plain-reply target from the batch's triggering event. +/// +/// Anchors to the trigger's own thread when it is itself a reply; otherwise the +/// trigger becomes the thread root, so the fallback reply lands in the same +/// place the agent's own `send_message` would have. +pub(crate) fn plain_reply_target_for(batch: &FlushBatch) -> Option { + let last = batch.events.last()?; + let parsed = crate::queue::parse_thread_tags(&last.event); + let trigger_hex = last.event.id.to_hex(); + let root = parsed.root_event_id.clone().unwrap_or(trigger_hex.clone()); + let parent = parsed.parent_event_id.clone().unwrap_or(trigger_hex); + Some(PlainReplyTarget { + channel_id: batch.channel_id, + thread_tags: ThreadTags { + root_event_id: Some(root), + parent_event_id: Some(parent), + mentioned_pubkeys: Vec::new(), + }, + }) +} + /// Best-effort: post a visible failure notice (kind:9) to a channel after a /// batch is dead-lettered. Replies into the thread of `thread_tags` when the /// triggering event was threaded. Errors are logged and swallowed — the @@ -3593,6 +3659,21 @@ pub(crate) async fn post_failure_notice( channel_id: Uuid, thread_tags: &ThreadTags, content: &str, +) { + post_threaded_message(rest, channel_id, thread_tags, content, "failure notice").await; +} + +/// Best-effort: post a threaded kind:9 message to a channel. +/// +/// Shared by the dead-letter failure notice and the plain-reply delivery +/// fallback; `what` only labels the log lines so the two are distinguishable. +/// Errors are logged and swallowed — publishing must never take down the loop. +async fn post_threaded_message( + rest: &crate::relay::RestClient, + channel_id: Uuid, + thread_tags: &ThreadTags, + content: &str, + what: &str, ) { let thread_ref = thread_tags.root_event_id.as_deref().and_then(|root| { let root_id = nostr::EventId::from_hex(root).ok()?; @@ -3610,21 +3691,21 @@ pub(crate) async fn post_failure_notice( match buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) { Ok(b) => b, Err(e) => { - tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); + tracing::warn!(channel = %channel_id, "{what}: build failed: {e}"); return; } }; let event = match builder.sign_with_keys(&rest.keys) { Ok(e) => e, Err(e) => { - tracing::warn!(channel = %channel_id, "failure notice: sign failed: {e}"); + tracing::warn!(channel = %channel_id, "{what}: sign failed: {e}"); return; } }; match tokio::time::timeout(Duration::from_secs(5), rest.submit_event(&event)).await { Ok(Ok(_)) => {} - Ok(Err(e)) => tracing::warn!(channel = %channel_id, "failure notice failed: {e}"), - Err(_) => tracing::warn!(channel = %channel_id, "failure notice timed out"), + Ok(Err(e)) => tracing::warn!(channel = %channel_id, "{what} failed: {e}"), + Err(_) => tracing::warn!(channel = %channel_id, "{what} timed out"), } } @@ -5405,6 +5486,7 @@ mod tests { agent_keys: agent_keys.clone(), agent_owner_pubkey: owner_pubkey, memory_enabled: false, + deliver_plain_replies: false, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), } diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 22d3f8b73e..d7891e224e 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1187,6 +1187,9 @@ fn parse_responses(v: Value) -> Result { Some("completed") => ProviderStop::EndTurn, _ => ProviderStop::Other, }; + if text.is_empty() && !reasoning.is_empty() && tool_calls.is_empty() { + text = reasoning.clone(); + } let input_tokens = sum_usage(&v, &["input_tokens"]); let output_tokens = sum_usage(&v, &["output_tokens"]); // The Responses API nests the cache split under `input_tokens_details`. @@ -1448,7 +1451,7 @@ fn parse_openai(v: Value) -> Result { let msg = choice .get("message") .ok_or_else(|| AgentError::Llm("missing message".into()))?; - let (text, block_reasoning) = openai_content_parts(msg.get("content")); + let (mut text, block_reasoning) = openai_content_parts(msg.get("content")); // DeepSeek and vLLM-style OpenAI-compat hosts expose reasoning tokens on the // message object. Prefer `reasoning_content` (DeepSeek's field name); fall // back to `reasoning` (some other providers), and last to reasoning blocks @@ -1495,6 +1498,13 @@ fn parse_openai(v: Value) -> Result { } } dedupe_provider_ids(&mut tool_calls); + // Some OpenAI-compat hosts (and local models served via mesh) return the + // whole answer as reasoning with an empty `content`. With no tool calls + // there is nothing else to surface, so promote reasoning to text rather + // than reporting an empty turn. + if text.is_empty() && !reasoning.is_empty() && tool_calls.is_empty() { + text = reasoning.clone(); + } let input_tokens = openai_chat_input_tokens(&v); let output_tokens = sum_usage(&v, &["completion_tokens"]); let cached_input_tokens = openai_chat_cached_tokens(&v); @@ -2681,6 +2691,21 @@ mod tests { assert_eq!(r.stop, ProviderStop::EndTurn); } + #[test] + fn parse_responses_uses_reasoning_when_text_is_empty() { + let v = serde_json::json!({ + "status": "completed", + "output": [{ + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "local model reply"}], + }], + }); + let r = parse_responses(v).unwrap(); + assert_eq!(r.text, "local model reply"); + assert_eq!(r.reasoning, "local model reply"); + assert!(r.tool_calls.is_empty()); + } + #[test] fn parse_responses_completed_with_function_call_is_tool_use() { let v = serde_json::json!({ @@ -4033,6 +4058,23 @@ mod tests { assert_eq!(parse_openai(v).unwrap().input_tokens, Some(123)); } + #[test] + fn parse_openai_uses_reasoning_content_when_content_is_empty() { + let v = serde_json::json!({ + "choices": [{ + "finish_reason": "stop", + "message": { + "content": "", + "reasoning_content": "local model reply" + } + }] + }); + let r = parse_openai(v).unwrap(); + assert_eq!(r.text, "local model reply"); + assert_eq!(r.reasoning, "local model reply"); + assert!(r.tool_calls.is_empty()); + } + #[test] fn parse_openai_databricks_prompt_tokens_already_inclusive() { // Databricks' MLflow route uses the OpenAI chat wire format diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs index 9b98974802..c8fcd84af2 100644 --- a/crates/buzz-dev-mcp/src/lib.rs +++ b/crates/buzz-dev-mcp/src/lib.rs @@ -13,6 +13,7 @@ use std::sync::Arc; mod paths; mod read_file; mod rg; +mod send_message; mod shell; mod shim; mod str_replace; @@ -27,13 +28,37 @@ struct DevMcp { tool_router: ToolRouter, } +/// Whether the harness asked for the first-class `send_message` reply tool. +/// +/// Set by `buzz-acp` for the shared-compute preset only. Any non-empty value +/// other than `0`/`false` enables it, so `1` and `true` both work. +fn send_message_tool_enabled() -> bool { + match std::env::var("BUZZ_MCP_SEND_MESSAGE_TOOL") { + Ok(v) => { + let v = v.trim(); + !(v.is_empty() || v == "0" || v.eq_ignore_ascii_case("false")) + } + Err(_) => false, + } +} + #[tool_router] impl DevMcp { fn new(state: Arc) -> Self { + let mut tool_router = Self::tool_router(); + // `send_message` is offered only when the harness asks for it via + // BUZZ_MCP_SEND_MESSAGE_TOOL. Adding a tool changes the toolset — and + // so the prompt — that every agent sees; only shared compute, where + // small local models need a typed reply path, has a measured benefit. + // Agents without it publish replies with `buzz messages send` via the + // shell tool, exactly as before this tool existed. + if !send_message_tool_enabled() { + tool_router.remove_route("send_message"); + } Self { state, todos: Arc::new(todo::TodoState::new()), - tool_router: Self::tool_router(), + tool_router, } } @@ -49,6 +74,18 @@ impl DevMcp { shell::run(&self.state, p, context.ct).await } + #[tool( + name = "send_message", + description = "Send a message to a Buzz channel. This is the ONLY way to say anything to a human: your assistant text and reasoning are never shown to anyone. Set `channel` to the channel UUID from the `[Context]` block, `content` to your message, and `reply_to` to the reply destination from `[Context]` when replying in a thread." + )] + async fn send_message( + &self, + Parameters(p): Parameters, + context: rmcp::service::RequestContext, + ) -> Result { + send_message::run(&self.state, p, context.ct).await + } + #[tool( name = "read_file", description = "Read a text file and return its contents with line numbers. Returns lines in `{number}:{content}` format. Use `offset` (0-based) and `limit` (default 2000) to window into large files. Path resolved relative to workdir (defaults to server cwd). Prefer over cat/head/tail." @@ -211,3 +248,50 @@ pub(crate) fn configure_no_window_async(cmd: &mut tokio::process::Command) { #[cfg(not(windows))] let _ = cmd; } + +#[cfg(test)] +mod send_message_gate_tests { + use super::send_message_tool_enabled; + + /// Serialize env mutation: these tests share process-global env. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn with_var(value: Option<&str>, f: impl FnOnce()) { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::var("BUZZ_MCP_SEND_MESSAGE_TOOL").ok(); + match value { + Some(v) => std::env::set_var("BUZZ_MCP_SEND_MESSAGE_TOOL", v), + None => std::env::remove_var("BUZZ_MCP_SEND_MESSAGE_TOOL"), + } + f(); + match prev { + Some(v) => std::env::set_var("BUZZ_MCP_SEND_MESSAGE_TOOL", v), + None => std::env::remove_var("BUZZ_MCP_SEND_MESSAGE_TOOL"), + } + } + + #[test] + fn unset_means_tool_is_not_offered() { + // Default for every non-shared-compute agent: toolset unchanged. + with_var(None, || assert!(!send_message_tool_enabled())); + } + + #[test] + fn truthy_values_enable_the_tool() { + for v in ["1", "true", "TRUE", "yes"] { + with_var(Some(v), || { + assert!(send_message_tool_enabled(), "expected {v:?} to enable") + }); + } + } + + #[test] + fn falsey_and_empty_values_leave_it_disabled() { + // Guards against an empty env var being read as opt-in. + for v in ["", " ", "0", "false", "False"] { + with_var(Some(v), || { + assert!(!send_message_tool_enabled(), "expected {v:?} to disable") + }); + } + } +} diff --git a/crates/buzz-dev-mcp/src/send_message.rs b/crates/buzz-dev-mcp/src/send_message.rs new file mode 100644 index 0000000000..41462cfae1 --- /dev/null +++ b/crates/buzz-dev-mcp/src/send_message.rs @@ -0,0 +1,267 @@ +//! First-class `send_message` tool: publish an agent reply to a Buzz channel. +//! +//! # Why this exists +//! +//! Before this tool, the only way for an agent to speak was to compose a +//! `buzz messages send …` command string inside a [`crate::shell`] call. That +//! indirection is reliable for large frontier models and unreliable for the +//! small local models Buzz shared compute is built to serve: given the real +//! ~3.2k-token Buzz system prompt, Gemma 4 E4B answered in prose instead of +//! calling `shell` in 6 of 8 samples — and in several it emitted the literal +//! command text as assistant content. Assistant text is never published, so +//! those replies were silently dropped. +//! +//! Exposing the publish action as its own typed tool removes the indirection: +//! the model picks a tool by name and fills one string field. Measured against +//! the real system prompt and the real dev-mcp toolset, delivery went from 2/8 +//! to 8/8 on E4B and 6/6 on 26B-A4B. +//! +//! The tool deliberately duplicates no process machinery: it builds an argv, +//! quotes it, and hands the command to [`crate::shell::run`], inheriting that +//! module's timeout, output capping, cancellation, and process-group kill. + +use crate::shell::{self, SharedState, ShellParams}; + +use rmcp::model::CallToolResult; +use rmcp::ErrorData; +use schemars::JsonSchema; +use serde::Deserialize; +use tokio_util::sync::CancellationToken; + +/// Timeout for a publish. A send is a single small relay round-trip; if it has +/// not completed in 30s the relay is unreachable and a longer wait only stalls +/// the turn. +const SEND_TIMEOUT_MS: u64 = 30_000; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SendMessageParams { + /// Channel UUID to post to — the one from the `[Context]` block. + pub channel: String, + /// The message text to publish. + pub content: String, + /// Optional event id to thread this reply under. Use the reply destination + /// supplied in `[Context]`. + #[serde(default)] + pub reply_to: Option, +} + +/// Single-quote `s` for POSIX shells: wrap in `'…'` and escape embedded quotes +/// as `'\''`. Every other byte — including newlines, `$`, backticks, and `;` — +/// is literal inside single quotes, so this is total rather than a blocklist. +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// Normalize an id the model copied out of the prompt, then validate it. +/// +/// The `[Context]` block renders a channel as `general (#adafd400-…)`, so +/// models routinely pass `#adafd400-…` or the whole `general (#adafd400-…)` +/// span. Both name the right channel, and failing them would push the model +/// back to prose — the exact failure this tool exists to remove. So a leading +/// `#` and a surrounding `name (…)` wrapper are stripped rather than rejected. +/// +/// Anything still not a bare id after normalization is rejected, so shell +/// metacharacters can never reach the command line. +fn normalize_id(kind: &str, value: &str) -> Result { + let mut v = value.trim(); + // `general (#uuid)` → `#uuid`. Only a `#`-prefixed span is unwrapped: + // unwrapping any parenthesized text would let `$(id)` become `id` and pass + // validation, turning this normalizer into a command-substitution escape. + if let (Some(open), Some(close)) = (v.rfind('('), v.rfind(')')) { + if open < close { + let inner = v[open + 1..close].trim(); + if inner.starts_with('#') { + v = inner; + } + } + } + let v = v.strip_prefix('#').unwrap_or(v).trim(); + + let ok = !v.is_empty() + && v.len() <= 64 + && v.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if ok { + Ok(v.to_string()) + } else { + Err(ErrorData::invalid_params( + format!("{kind} must be a bare id (alphanumeric, '-', '_'); got {value:?}"), + None, + )) + } +} + +/// Build the `buzz messages send` command line for `p`. +/// +/// Split out from [`run`] so the quoting and flag order are unit-testable +/// without spawning a process. +fn build_command(p: &SendMessageParams) -> Result { + let channel = normalize_id("channel", &p.channel)?; + let reply_to = match &p.reply_to { + // An explicitly empty `reply_to` means "no thread", not a bad argument: + // models fill optional string fields with "" rather than omitting them. + Some(v) if v.trim().is_empty() => None, + Some(v) => Some(normalize_id("reply_to", v)?), + None => None, + }; + if p.content.trim().is_empty() { + return Err(ErrorData::invalid_params( + "content must not be empty".to_string(), + None, + )); + } + + let mut cmd = format!( + "buzz messages send --channel {} --content {}", + shell_quote(&channel), + shell_quote(&p.content) + ); + if let Some(reply_to) = &reply_to { + cmd.push_str(&format!(" --reply-to {}", shell_quote(reply_to))); + } + Ok(cmd) +} + +pub async fn run( + state: &SharedState, + p: SendMessageParams, + ct: CancellationToken, +) -> Result { + let command = build_command(&p)?; + shell::run( + state, + ShellParams { + command, + workdir: None, + timeout_ms: Some(SEND_TIMEOUT_MS), + }, + ct, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(channel: &str, content: &str, reply_to: Option<&str>) -> SendMessageParams { + SendMessageParams { + channel: channel.to_string(), + content: content.to_string(), + reply_to: reply_to.map(str::to_string), + } + } + + #[test] + fn builds_plain_send() { + let cmd = build_command(¶ms("abc-123", "hello there", None)).unwrap(); + assert_eq!( + cmd, + "buzz messages send --channel 'abc-123' --content 'hello there'" + ); + } + + #[test] + fn threads_reply_when_reply_to_present() { + let cmd = build_command(¶ms("abc-123", "hi", Some("deadbeef"))).unwrap(); + assert_eq!( + cmd, + "buzz messages send --channel 'abc-123' --content 'hi' --reply-to 'deadbeef'" + ); + } + + #[test] + fn quotes_content_containing_single_quotes() { + let cmd = build_command(¶ms("c", "it's fine", None)).unwrap(); + assert_eq!( + cmd, + r"buzz messages send --channel 'c' --content 'it'\''s fine'" + ); + } + + /// Content is attacker-influenced (it is model output, which can echo + /// untrusted channel text), so shell metacharacters must stay inert. + #[test] + fn neutralizes_shell_metacharacters_in_content() { + for payload in [ + "; rm -rf /", + "$(whoami)", + "`id`", + "a && b", + "x | tee /tmp/y", + "line1\nline2", + "$HOME", + ] { + let cmd = build_command(¶ms("c", payload, None)).unwrap(); + let quoted = shell_quote(payload); + assert!( + cmd.ends_with(&format!("--content {quoted}")), + "payload {payload:?} must be fully single-quoted, got {cmd}" + ); + // Nothing escapes the quoted region: the only unquoted single + // quotes are the ones our escaping introduces. + assert_eq!( + cmd.matches('\'').count() % 2, + 0, + "unbalanced quoting for {payload:?}" + ); + } + } + + #[test] + fn rejects_channel_with_shell_metacharacters() { + let err = build_command(¶ms("abc; rm -rf /", "hi", None)).unwrap_err(); + assert!(err.message.contains("channel must be a bare id")); + } + + #[test] + fn rejects_reply_to_with_shell_metacharacters() { + let err = build_command(¶ms("abc", "hi", Some("$(id)"))).unwrap_err(); + assert!(err.message.contains("reply_to must be a bare id")); + } + + #[test] + fn rejects_empty_channel() { + assert!(build_command(¶ms("", "hi", None)).is_err()); + } + + #[test] + fn rejects_blank_content() { + // A whitespace-only reply is never a deliberate message; failing here + // surfaces the mistake to the model instead of posting an empty event. + assert!(build_command(¶ms("c", " ", None)).is_err()); + } + + #[test] + fn strips_hash_prefix_from_channel() { + // `[Context]` renders `general (#uuid)`, so models pass `#uuid`. + let cmd = build_command(¶ms("#abc-123", "hi", None)).unwrap(); + assert_eq!(cmd, "buzz messages send --channel 'abc-123' --content 'hi'"); + } + + #[test] + fn unwraps_full_context_channel_span() { + let cmd = build_command(¶ms("general (#abc-123)", "hi", None)).unwrap(); + assert_eq!(cmd, "buzz messages send --channel 'abc-123' --content 'hi'"); + } + + #[test] + fn treats_empty_reply_to_as_absent() { + // Small models fill optional string fields with "" instead of omitting. + let cmd = build_command(¶ms("abc", "hi", Some(""))).unwrap(); + assert_eq!(cmd, "buzz messages send --channel 'abc' --content 'hi'"); + } + + #[test] + fn normalization_cannot_smuggle_metacharacters() { + // Stripping must not become an escape hatch: the inner value is still + // validated, so a quoted payload is rejected rather than unwrapped. + assert!(build_command(¶ms("x (#a; rm -rf /)", "hi", None)).is_err()); + assert!(build_command(¶ms("#$(id)", "hi", None)).is_err()); + } + + #[test] + fn rejects_overlong_channel() { + assert!(build_command(¶ms(&"a".repeat(65), "hi", None)).is_err()); + } +} diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 01f78a2d49..41bdc3b9e9 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -84,8 +84,8 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } buzz-core = { workspace = true, features = ["test-utils"] } buzz-auth = { workspace = true, features = ["dev"] } reqwest = { workspace = true } diff --git a/crates/buzz-relay/examples/mesh_agent_e2e.rs b/crates/buzz-relay/examples/mesh_agent_e2e.rs index b6f723f35a..7e55d07b5d 100644 --- a/crates/buzz-relay/examples/mesh_agent_e2e.rs +++ b/crates/buzz-relay/examples/mesh_agent_e2e.rs @@ -8,17 +8,31 @@ //! P2 auto-model chat — agent sends `model: "auto"`; mesh router picks. //! P3 context-fit regression — an oversized output budget (150k tokens) //! must FAIL with the router's context error (proves the router's fit -//! gate — the failure mode the 1024 preset cap protects against). +//! gate — the failure mode the 4096 preset cap protects against). //! P4 agentic tool use — agent + buzz-dev-mcp writes a file on disk. +//! P5 Buzz reply command — agent asks the real developer MCP to run the +//! exact `buzz messages send` command used to publish a reply. A fake +//! shell records the command at the execution boundary, so this proves +//! the model emits the right command but NOT that a reply lands. +//! P6 live relay send — the genuine `buzz messages send` (dev-mcp's +//! multicall shim, real buzz-cli) against a running relay. The harness +//! then queries the relay itself: the agent's prose is not evidence. +//! Skipped unless the live-relay env vars below are set. //! //! The serve node is the same `mesh_llm_sdk::serve` path Share-compute uses //! (publish off, mdns, loopback). The agent legs spawn the real //! `buzz-agent` binary with the exact env vars the relay-mesh preset ships. //! //! Hardware-gated, not CI. Run: -//! cargo build --release -p buzz-agent -p buzz-dev-mcp +//! cargo build --release -p buzz-agent -p buzz-dev-mcp -p buzz-cli //! cargo run -p buzz-relay --example mesh_agent_e2e //! Env: MESH_E2E_MODEL overrides the served model ref. +//! +//! P6 additionally needs a relay (`just relay` — Postgres + Redis) plus: +//! BUZZ_E2E_RELAY_URL=ws://localhost:3000 +//! BUZZ_E2E_PRIVATE_KEY= +//! BUZZ_E2E_CHANNEL= +//! All three must be set or P6 is skipped (P1-P5 still run). use std::process::Stdio; use std::time::Duration; @@ -26,14 +40,43 @@ use mesh_llm_sdk::{serve, MeshDiscoveryMode}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, Command}; -// Qwen3-8B: cached GGUF *and* complete layer package on this class of -// machine, so the serve node starts in seconds. Qwen3-30B-A3B works too but -// mesh-llm serves it from layer packages and will download them on first -// serve (~7GB) — fine in the app (progress UI), too slow for a smoke. -const DEFAULT_MODEL: &str = "unsloth/Qwen3-8B-GGUF:Q4_K_M"; +// Non-reasoning Gemma 4 is the small Buzz-curated default. It reaches tool +// calls promptly instead of spending the output budget in a hidden reasoning +// loop. MESH_E2E_MODEL can still exercise any other served model explicitly. +const DEFAULT_MODEL: &str = "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"; const API_PORT: u16 = 19437; const CONSOLE_PORT: u16 = 13231; +#[derive(Clone)] +struct McpServerEnv { + name: String, + value: String, +} + +#[derive(Clone)] +struct McpServerSpec { + name: String, + command: String, + env: Vec, +} + +impl McpServerSpec { + fn new(name: impl Into, command: impl Into) -> Self { + Self { + name: name.into(), + command: command.into(), + env: Vec::new(), + } + } + + fn set_env(&mut self, name: impl Into, value: impl Into) { + self.env.push(McpServerEnv { + name: name.into(), + value: value.into(), + }); + } +} + fn main() -> anyhow::Result<()> { tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -167,7 +210,7 @@ async fn run() -> anyhow::Result<()> { let prompt = format!( "Use your developer tools to create {marker_name} in the current working directory containing exactly the text BUZZ_OK (no quotes, no newline commentary). Then confirm." ); - let mcp = vec![("dev".to_string(), repo_bin("buzz-dev-mcp")?)]; + let mcp = vec![McpServerSpec::new("dev", repo_bin("buzz-dev-mcp")?)]; let (r, marker) = agent_chat_with_marker(&base, &served_id, None, &prompt, &mcp, &marker_name).await; let file_ok = std::fs::read_to_string(&marker) @@ -187,6 +230,83 @@ async fn run() -> anyhow::Result<()> { } let _ = std::fs::remove_file(&marker); + // P5: prove the model follows Buzz's real reply path. buzz-dev-mcp always + // prepends its own multicall `buzz` shim to PATH, so a fake `buzz` later on + // PATH cannot isolate this test. Instead, inject a fake shell into the real + // MCP server. It records the exact command at the execution boundary and + // returns the same accepted-write shape as buzz-cli, without contacting a + // community. + let send_marker = format!("mesh-e2e-buzz-send-{}.txt", std::process::id()); + let prompt = "Use the developer shell tool to run exactly this command: buzz messages send --channel test-channel --content BUZZ_REPLY_OK. Do not simulate or substitute another command. After it succeeds, confirm briefly."; + let (r, marker) = + agent_chat_with_fake_shell(&base, &served_id, prompt, &mcp, &send_marker).await; + let recorded = std::fs::read_to_string(&marker).unwrap_or_default(); + // The fake shell appends every command it is handed. Small models often + // probe with an unrelated command first, so the pass condition is that the + // exact Buzz send reached the execution boundary on some line — not that it + // was the only command attempted. + const EXPECTED_SEND: &str = "buzz messages send --channel test-channel --content BUZZ_REPLY_OK"; + let attempts: Vec<&str> = recorded + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect(); + let send_ok = attempts.contains(&EXPECTED_SEND); + match r { + Ok(text) => record( + "P5 Buzz reply command", + send_ok, + format!("commands attempted {attempts:?}; agent said: {text}"), + ), + Err(e) => record( + "P5 Buzz reply command", + send_ok, + format!("agent error: {e}; commands attempted: {attempts:?}"), + ), + } + let _ = std::fs::remove_file(&marker); + + // P6: live relay end-to-end. P5 stops at the shell boundary with a fake + // shell, so it cannot prove a reply ever reaches a channel. Here the agent + // runs the genuine `buzz messages send` (dev-mcp's multicall shim, real + // buzz-cli code) against a running relay, and the harness independently + // queries the relay afterwards rather than trusting the agent's prose. + // + // Infra-gated: needs `just relay` (Postgres + Redis) and an identity: + // BUZZ_E2E_RELAY_URL, BUZZ_E2E_PRIVATE_KEY, BUZZ_E2E_CHANNEL + match ( + std::env::var("BUZZ_E2E_RELAY_URL").ok(), + std::env::var("BUZZ_E2E_PRIVATE_KEY").ok(), + std::env::var("BUZZ_E2E_CHANNEL").ok(), + ) { + (Some(relay_url), Some(private_key), Some(channel)) => { + let live_marker = format!("BUZZ_LIVE_OK_{}", std::process::id()); + let mut dev = McpServerSpec::new("dev", repo_bin("buzz-dev-mcp")?); + dev.set_env("BUZZ_RELAY_URL", relay_url.as_str()); + dev.set_env("BUZZ_PRIVATE_KEY", private_key.as_str()); + let prompt = format!( + "You are a Buzz agent with shell access. The `buzz` CLI is on your PATH; `buzz messages send` publishes to a channel. Post a message containing exactly the token {live_marker} to channel {channel}." + ); + let r = agent_chat(&base, &served_id, None, &prompt, &[dev]).await; + let landed = relay_has_message(&relay_url, &private_key, &channel, &live_marker).await; + let landed_ok = matches!(landed, Ok(true)); + let detail = match (&r, &landed) { + (Ok(text), Ok(true)) => { + format!("relay returned {live_marker}; agent said: {text}") + } + (Ok(text), Ok(false)) => { + format!("{live_marker} never reached the relay; agent said: {text}") + } + (Ok(text), Err(e)) => format!("relay verification failed: {e}; agent said: {text}"), + (Err(e), _) => format!("agent error: {e}"), + }; + record("P6 live relay send", landed_ok, detail); + } + _ => eprintln!( + "[e2e] SKIP P6 live relay send (set BUZZ_E2E_RELAY_URL, BUZZ_E2E_PRIVATE_KEY, BUZZ_E2E_CHANNEL)" + ), + } + eprintln!("[e2e] {pass} passed, {fail} failed"); if fail > 0 { eprintln!("[e2e] FAIL: {fail} permutation(s) failed"); @@ -220,6 +340,45 @@ fn repo_bin(name: &str) -> anyhow::Result { Ok(path.to_string_lossy().into_owned()) } +/// Ask the relay directly whether `marker` is present in `channel`. +/// +/// This is the independent check that makes P6 meaningful: the agent's prose is +/// not evidence, and neither is the shell exit code. The harness performs its +/// own authenticated read against the relay in a separate process and only +/// trusts what comes back. Polls because the relay persists and fans out +/// asynchronously, so an immediate read can race the write. +async fn relay_has_message( + relay_url: &str, + private_key: &str, + channel: &str, + marker: &str, +) -> anyhow::Result { + let buzz = repo_bin("buzz")?; + for _ in 0..15 { + let output = Command::new(&buzz) + .args([ + "--format", + "compact", + "messages", + "get", + "--channel", + channel, + "--limit", + "50", + ]) + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("BUZZ_RELAY_URL", relay_url) + .env("BUZZ_PRIVATE_KEY", private_key) + .output() + .await?; + if output.status.success() && String::from_utf8_lossy(&output.stdout).contains(marker) { + return Ok(true); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + Ok(false) +} + /// Spawn the real buzz-agent with relay-mesh preset env and drive one ACP /// session/prompt over stdio. Returns the concatenated agent message text, /// or Err carrying the agent's error message. @@ -228,10 +387,11 @@ async fn agent_chat( model: &str, max_output_tokens: Option<&str>, prompt: &str, - mcp_servers: &[(String, String)], + mcp_servers: &[McpServerSpec], ) -> anyhow::Result { let (result, _) = - agent_chat_in_isolated_home(base, model, max_output_tokens, prompt, mcp_servers).await; + agent_chat_in_isolated_home(base, model, max_output_tokens, prompt, mcp_servers, None) + .await; result } @@ -240,11 +400,25 @@ async fn agent_chat_with_marker( model: &str, max_output_tokens: Option<&str>, prompt: &str, - mcp_servers: &[(String, String)], + mcp_servers: &[McpServerSpec], + marker_name: &str, +) -> (anyhow::Result, std::path::PathBuf) { + let (result, home) = + agent_chat_in_isolated_home(base, model, max_output_tokens, prompt, mcp_servers, None) + .await; + (result, home.join(marker_name)) +} + +async fn agent_chat_with_fake_shell( + base: &str, + model: &str, + prompt: &str, + mcp_servers: &[McpServerSpec], marker_name: &str, ) -> (anyhow::Result, std::path::PathBuf) { let (result, home) = - agent_chat_in_isolated_home(base, model, max_output_tokens, prompt, mcp_servers).await; + agent_chat_in_isolated_home(base, model, None, prompt, mcp_servers, Some(marker_name)) + .await; (result, home.join(marker_name)) } @@ -253,7 +427,8 @@ async fn agent_chat_in_isolated_home( model: &str, max_output_tokens: Option<&str>, prompt: &str, - mcp_servers: &[(String, String)], + mcp_servers: &[McpServerSpec], + fake_shell_marker_name: Option<&str>, ) -> (anyhow::Result, std::path::PathBuf) { let agent = match repo_bin("buzz-agent") { Ok(agent) => agent, @@ -265,10 +440,28 @@ async fn agent_chat_in_isolated_home( return (Err(error.into()), home); } + let child_path = std::env::var("PATH").unwrap_or_default(); + let mut effective_mcp_servers = mcp_servers.to_vec(); + if let Some(marker_name) = fake_shell_marker_name { + let fake_shell = match install_fake_shell(&home) { + Ok(fake_shell) => fake_shell, + Err(error) => return (Err(error), home), + }; + let marker = home.join(marker_name); + let Some(dev_server) = effective_mcp_servers + .iter_mut() + .find(|server| server.name == "dev") + else { + return (Err(anyhow::anyhow!("P5 requires the dev MCP server")), home); + }; + dev_server.set_env("BUZZ_SHELL", fake_shell.to_string_lossy()); + dev_server.set_env("BUZZ_E2E_SEND_MARKER", marker.to_string_lossy()); + } + let mut command = Command::new(&agent); command .env_clear() - .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("PATH", child_path) .env("HOME", &home) // Exactly the environment apply_relay_mesh_env() supplies. .env("BUZZ_AGENT_PROVIDER", "openai") @@ -278,7 +471,9 @@ async fn agent_chat_in_isolated_home( .env("OPENAI_COMPAT_API_KEY", "buzz-mesh-local") .env("OPENAI_COMPAT_API", "chat") .env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "4096") - .env("BUZZ_AGENT_THINKING_EFFORT", "none") + // No BUZZ_AGENT_THINKING_EFFORT: apply_relay_mesh_env() deliberately + // leaves it unset so each model's chat template picks its own default. + // Pinning a value here would test a config the product does not ship. .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); @@ -293,15 +488,69 @@ async fn agent_chat_in_isolated_home( Err(error) => return (Err(error.into()), home), }; - let result = drive_acp(&mut child, prompt, mcp_servers, &home).await; + let result = drive_acp(&mut child, prompt, &effective_mcp_servers, &home).await; let _ = child.kill().await; (result, home) } +#[cfg(unix)] +fn install_fake_shell(home: &std::path::Path) -> anyhow::Result { + use std::os::unix::fs::PermissionsExt; + + let bin_dir = home.join("bin"); + std::fs::create_dir_all(&bin_dir)?; + let executable = bin_dir.join("mesh-e2e-shell"); + // buzz-dev-mcp spawns ` -c ""` on Unix (`shell_flag()` + // returns `-c` for every non-cmd/pwsh shell). Accept `-lc` too so a future + // login-shell dialect change does not silently stop recording. + // Every command is appended, one per line: a small model often probes with + // an unrelated command first, and overwriting would discard the real send. + std::fs::write( + &executable, + r#"#!/bin/sh +case "$1" in + -c|-lc) command_string="$2" ;; + *) + printf 'unsupported fake shell invocation: %s\n' "$*" >&2 + exit 2 + ;; +esac +printf '%s\n' "$command_string" >> "$BUZZ_E2E_SEND_MARKER" +if [ "$command_string" = "buzz messages send --channel test-channel --content BUZZ_REPLY_OK" ]; then + printf '%s\n' '{"event_id":"mesh-e2e-event","accepted":true,"message":"ok"}' + exit 0 +fi +printf 'unsupported fake shell command: %s\n' "$command_string" >&2 +exit 2 +"#, + )?; + let mut permissions = std::fs::metadata(&executable)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions)?; + // BUZZ_SHELL must name the executable itself. Returning the directory made + // `resolve_bash()` reject it as "not a file" and fall back to real bash, so + // the fake shell never ran and P5 could never record a command. + Ok(executable) +} + +#[cfg(not(unix))] +fn install_fake_shell(_home: &std::path::Path) -> anyhow::Result { + anyhow::bail!("the mesh agent hardware harness currently requires Unix") +} + +/// The real Buzz system prompt every managed agent receives, straight from the +/// harness crate rather than a paraphrase. +/// +/// A terse hand-written test prompt is not a proxy for this: it is ~3.2k tokens +/// of instructions, and the reply-delivery rules the model must follow to get a +/// message into a channel live in here. Testing with a short prompt measures a +/// toy, not Buzz. +const BUZZ_BASE_PROMPT: &str = include_str!("../../buzz-acp/src/base_prompt.md"); + async fn drive_acp( child: &mut Child, prompt: &str, - mcp_servers: &[(String, String)], + mcp_servers: &[McpServerSpec], cwd: &std::path::Path, ) -> anyhow::Result { let mut stdin = child @@ -316,8 +565,18 @@ async fn drive_acp( let mcp_json: Vec = mcp_servers .iter() - .map(|(name, command)| { - serde_json::json!({ "name": name, "command": command, "args": [], "env": [] }) + .map(|server| { + let env: Vec = server + .env + .iter() + .map(|entry| serde_json::json!({ "name": &entry.name, "value": &entry.value })) + .collect(); + serde_json::json!({ + "name": &server.name, + "command": &server.command, + "args": [], + "env": env, + }) }) .collect(); @@ -338,7 +597,7 @@ async fn drive_acp( "params": { "cwd": cwd.to_string_lossy(), "mcpServers": mcp_json, - "systemPrompt": "You are a terse test agent. Follow instructions exactly." + "systemPrompt": BUZZ_BASE_PROMPT } })) .as_bytes(), diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index d4f7a4a2d4..49e6ef9fba 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -75,6 +75,19 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if 1.0.4", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -1508,7 +1521,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1745,7 +1758,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -2149,7 +2162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -3099,8 +3112,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -3845,7 +3858,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.5.3", + "windows-registry 0.6.1", ] [[package]] @@ -3860,7 +3873,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4936,8 +4949,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "mesh-llm-client", @@ -4946,8 +4959,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4957,13 +4970,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-llm-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -4994,8 +5007,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5010,8 +5023,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5020,8 +5033,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "clap", @@ -5032,8 +5045,8 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "cc", @@ -5045,8 +5058,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -5054,16 +5067,16 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "mesh-llm-native-runtime", ] [[package]] name = "mesh-llm-host-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "argon2", @@ -5085,7 +5098,6 @@ dependencies = [ "http", "http-body-util", "httparse", - "if-addrs", "iroh", "json5", "keyring", @@ -5133,6 +5145,7 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "skippy-coordinator", + "skippy-ffi", "skippy-protocol", "skippy-runtime", "skippy-server", @@ -5155,8 +5168,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "argon2", "base64 0.22.1", @@ -5177,8 +5190,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "serde", @@ -5188,8 +5201,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-types", @@ -5202,8 +5215,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5219,8 +5232,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5230,6 +5243,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "sha2 0.10.9", "tar", "tempfile", "zip 2.4.2", @@ -5237,8 +5251,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "hex", @@ -5250,16 +5264,16 @@ dependencies = [ [[package]] name = "mesh-llm-routing" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5281,8 +5295,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5296,8 +5310,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5307,8 +5321,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "chrono", @@ -5330,8 +5344,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "serde", @@ -5341,13 +5355,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-mixture-of-agents" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5419,8 +5433,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5430,8 +5444,8 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5448,8 +5462,8 @@ dependencies = [ [[package]] name = "model-package" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "bytes", @@ -5468,16 +5482,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "model-artifact", @@ -6131,7 +6145,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.118", @@ -6505,8 +6519,8 @@ dependencies = [ [[package]] name = "openai-frontend" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "axum", @@ -6699,7 +6713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -7409,7 +7423,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools", "log", "multimap", @@ -9038,8 +9052,8 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "blake3", @@ -9048,29 +9062,29 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "libloading 0.8.9", ] [[package]] name = "skippy-metrics" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "skippy-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "prost", "prost-build", @@ -9080,8 +9094,8 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "libc", @@ -9094,9 +9108,10 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ + "ahash", "anyhow", "async-trait", "axum", @@ -9122,8 +9137,8 @@ dependencies = [ [[package]] name = "skippy-topology" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -10150,7 +10165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -11786,7 +11801,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -12415,8 +12430,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8bb643fea3..c44568d983 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -90,14 +90,14 @@ buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } iroh = { version = "1.0.2", optional = true } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } # Model catalog + hardware survey for the Share-compute model picker (same # diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client. -mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-client", optional = true } -mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-node", optional = true } -mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-system", optional = true } -mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-events", optional = true } +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 305c54a203..998bc6e7d2 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -5,12 +5,35 @@ use tauri::{AppHandle, Manager, State}; use crate::{app_state::AppState, mesh_llm, relay}; -#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] struct MeshSharingConfig { enabled: bool, + /// A fresh Share Compute request that must cross a process boundary before + /// it can start. Consumed before startup so an interrupted download is not + /// resumed on a later launch. + #[serde(default)] + start_on_next_launch: bool, model_id: String, max_vram_gb: Option, + /// Community relay where Share Compute was explicitly enabled. Older + /// configs predate community binding and restore against the active relay. + #[serde(default)] + relay_url: Option, +} + +fn pending_new_start_checkpoint(config: &MeshSharingConfig) -> MeshSharingConfig { + let mut checkpoint = config.clone(); + checkpoint.enabled = false; + checkpoint.start_on_next_launch = false; + checkpoint +} + +fn one_shot_restart_checkpoint(config: &MeshSharingConfig) -> MeshSharingConfig { + let mut checkpoint = config.clone(); + checkpoint.enabled = false; + checkpoint.start_on_next_launch = true; + checkpoint } fn mesh_sharing_config_path(app: &AppHandle) -> Result { @@ -89,8 +112,10 @@ fn sharing_config_from_request( .ok_or_else(|| "modelId is required for serve mode".to_string())?; Ok(MeshSharingConfig { enabled: true, + start_on_next_launch: false, model_id: model_id.to_string(), max_vram_gb: request.max_vram_gb, + relay_url: request.relay_url.clone(), }) } @@ -117,7 +142,7 @@ fn restart_to_share( app: &AppHandle, config: &MeshSharingConfig, ) -> CmdResult { - save_mesh_sharing_config(app, config)?; + save_mesh_sharing_config(app, &one_shot_restart_checkpoint(config))?; let status = restarting_share_status(config); app.request_restart(); Ok(status) @@ -133,7 +158,7 @@ fn buzz_mesh_name_for_relay(relay_url: &str) -> String { format!("buzz-community-{}", &digest[..32]) } -fn buzz_mesh_name(state: &AppState) -> String { +pub(super) fn buzz_mesh_name(state: &AppState) -> String { buzz_mesh_name_for_relay(&relay::relay_ws_url_with_override(state)) } @@ -150,8 +175,13 @@ fn advance_mesh_status_cursor( Ok(cursor) } -async fn query_mesh_discovery_events(state: &AppState) -> Result, String> { - let mut events = relay::query_relay(state, &[mesh_llm::relay_membership_filter()]).await?; +async fn query_mesh_discovery_events_at( + state: &AppState, + relay_url: &str, +) -> Result, String> { + let api_base_url = relay::relay_http_base_url(relay_url); + let mut events = + relay::query_relay_at(state, &api_base_url, &[mesh_llm::relay_membership_filter()]).await?; let member_pubkeys = mesh_llm::current_member_pubkeys(&events); if member_pubkeys.is_empty() { // Distinguish "relay returned a membership snapshot listing zero @@ -172,7 +202,7 @@ async fn query_mesh_discovery_events(state: &AppState) -> Result = None; loop { - let page = relay::query_relay(state, &[status_filter.clone()]).await?; + let page = relay::query_relay_at(state, &api_base_url, &[status_filter.clone()]).await?; let done = page.len() < mesh_llm::MESH_STATUS_PAGE_SIZE; if !done { let cursor = advance_mesh_status_cursor(&mut status_filter, &page)?; @@ -188,6 +218,10 @@ async fn query_mesh_discovery_events(state: &AppState) -> Result Result, String> { + query_mesh_discovery_events_at(state, &relay::relay_ws_url_with_override(state)).await +} + /// Resolve the admission roster by intersecting member-signed mesh status /// reporters with the current NIP-43 direct-member list. /// @@ -201,6 +235,14 @@ pub(crate) async fn resolve_trusted_owner_ids(state: &AppState) -> Result Result, String> { + let events = query_mesh_discovery_events_at(state, relay_url).await?; + Ok(mesh_llm::owner_ids_from_events(&events)) +} + /// Resolve the roster for an initial node *start*, failing closed to self-only /// (an empty roster) when the relay query fails. This is safe only at start: /// there is no established allowlist to preserve yet. The periodic @@ -244,10 +286,11 @@ fn buzz_mesh_join_targets( /// Resolve the validated member endpoint this runtime should join to enter the /// existing Buzz community mesh. `Ok(None)` means this machine is the first /// live serving member (or is itself the shared bootstrap contact). -pub(crate) async fn resolve_buzz_mesh_join_targets( +pub(crate) async fn resolve_buzz_mesh_join_targets_at( state: &AppState, + relay_url: &str, ) -> Result, String> { - let events = query_mesh_discovery_events(state).await?; + let events = query_mesh_discovery_events_at(state, relay_url).await?; let self_owner_id = mesh_llm::ensure_owner_identity() .map_err(|error| format!("failed to load mesh owner identity: {error}"))? .owner_id; @@ -261,8 +304,11 @@ pub(crate) async fn resolve_buzz_mesh_join_targets( /// snapshot. A node start used to repeat the full membership + status query /// for each value, making Share Compute startup both slower and more exposed /// to inconsistent snapshots. -async fn resolve_buzz_mesh_startup(state: &AppState) -> (Vec, Option) { - match query_mesh_discovery_events(state).await { +async fn resolve_buzz_mesh_startup_at( + state: &AppState, + relay_url: &str, +) -> (Vec, Option) { + match query_mesh_discovery_events_at(state, relay_url).await { Ok(events) => { let trusted_owner_ids = mesh_llm::owner_ids_from_events(&events); let join_token = mesh_llm::ensure_owner_identity() @@ -291,32 +337,60 @@ async fn resolve_buzz_mesh_startup(state: &AppState) -> (Vec, Option CmdResult<()> { - let Some(config) = load_mesh_sharing_config(app)? else { + let Some(mut config) = load_mesh_sharing_config(app)? else { return Ok(()); }; - if !config.enabled || config.model_id.trim().is_empty() { + if (!config.enabled && !config.start_on_next_launch) || config.model_id.trim().is_empty() { return Ok(()); } + config.model_id = mesh_llm::canonical_curated_model_id(&config.model_id).to_string(); if state.mesh_llm_runtime.lock().await.is_some() { return Ok(()); } - let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup(state).await; + let relay_url = config + .relay_url + .clone() + .unwrap_or_else(|| relay::relay_ws_url_with_override(state)); + let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup_at(state, &relay_url).await; let mut runtime = state.mesh_llm_runtime.lock().await; if runtime.is_some() { return Ok(()); } + if config.start_on_next_launch { + // Consume a role-switch request before doing any potentially long model + // work. If Buzz exits during that work, the next launch stays stopped. + config = pending_new_start_checkpoint(&config); + save_mesh_sharing_config(app, &config)?; + } + // This is restoration of a previously inference-ready serving node. Keep + // the enabled checkpoint armed while restoring so a transient startup + // failure does not silently turn Share Compute off. New starts remain + // disarmed in `mesh_start_node` until their first inference probe passes. let request = mesh_llm::StartMeshNodeRequest { mode: mesh_llm::MeshNodeMode::Serve, - model_id: Some(config.model_id), + model_id: Some(config.model_id.clone()), max_vram_gb: config.max_vram_gb, join_token, - mesh_name: Some(buzz_mesh_name(state)), + mesh_name: Some(buzz_mesh_name_for_relay(&relay_url)), + relay_url: Some(relay_url), trusted_owner_ids: Some(trusted_owner_ids), }; let started = mesh_llm::DesktopMeshRuntime::start(request) .await .map_err(|error| format!("failed to restore Share Compute: {error:#}"))?; + if let Err(error) = wait_for_mesh_inference(&config.model_id).await { + let cleanup = started.stop().await; + if let Err(cleanup_error) = cleanup { + eprintln!( + "buzz-mesh: restored node failed inference readiness and cleanup was incomplete: {cleanup_error:#}" + ); + } + return Err(format!("failed to restore Share Compute: {error}")); + } *runtime = Some(started); + config.enabled = true; + config.start_on_next_launch = false; + save_mesh_sharing_config(app, &config)?; drop(runtime); mesh_llm::publish_current_status_once(app, "restore").await; Ok(()) @@ -328,6 +402,11 @@ pub async fn mesh_start_node( state: State<'_, AppState>, mut request: mesh_llm::StartMeshNodeRequest, ) -> CmdResult { + let relay_url = relay::relay_ws_url_with_override(&state); + request.relay_url = Some(relay_url.clone()); + if let Some(model_id) = request.model_id.as_mut() { + *model_id = mesh_llm::canonical_curated_model_id(model_id).to_string(); + } let sharing_config = if request.mode == mesh_llm::MeshNodeMode::Serve { Some(sharing_config_from_request(&request)?) } else { @@ -362,13 +441,14 @@ pub async fn mesh_start_node( // Frontend requests never carry a roster. Resolve it and the bootstrap // endpoint from one snapshot so UI startup does not repeat relay probes. if request.trusted_owner_ids.is_none() || request.join_token.is_none() { - let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup(&state).await; + let (trusted_owner_ids, join_token) = + resolve_buzz_mesh_startup_at(&state, &relay_url).await; request.trusted_owner_ids.get_or_insert(trusted_owner_ids); if request.join_token.is_none() { request.join_token = join_token; } } - request.mesh_name = Some(buzz_mesh_name(&state)); + request.mesh_name = Some(buzz_mesh_name_for_relay(&relay_url)); let mut runtime = state.mesh_llm_runtime.lock().await; let plan = match runtime.as_ref() { @@ -386,6 +466,13 @@ pub async fn mesh_start_node( return Err("mesh node is already running".to_string()); } + if let Some(config) = sharing_config.as_ref() { + // Do not arm launch restoration until the exact inference path used by + // agents succeeds. Mesh may bind its ports after primary weights load + // while package layers are still downloading. + save_mesh_sharing_config(&app, &pending_new_start_checkpoint(config))?; + } + let started = mesh_llm::DesktopMeshRuntime::start(request) .await .map_err(|error| format!("{error:#}"))?; @@ -409,6 +496,21 @@ pub async fn mesh_start_node( )); } }; + if let Some(config) = sharing_config.as_ref() { + if let Err(error) = wait_for_mesh_inference(&config.model_id).await { + let cleanup = started.stop().await; + if let Err(cleanup_error) = &cleanup { + eprintln!( + "buzz-mesh: started node failed inference readiness and cleanup was incomplete: {cleanup_error:#}" + ); + } + drop(runtime); + app.request_restart(); + return Err(format!( + "mesh node started but inference never became ready: {error}; Buzz is restarting to guarantee cleanup" + )); + } + } *runtime = Some(started); drop(runtime); if let Some(config) = sharing_config.as_ref() { @@ -612,6 +714,7 @@ pub(crate) async fn ensure_client_node_for_model( max_vram_gb: None, join_token: Some(join_token.clone()), mesh_name: Some(buzz_mesh_name(state)), + relay_url: Some(relay::relay_ws_url_with_override(state)), trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await), }; let mut runtime = state.mesh_llm_runtime.lock().await; @@ -753,6 +856,18 @@ pub(crate) async fn ensure_relay_mesh_for_record( } } } + + // A persisted Share Compute configuration is authoritative about this + // machine's role. If no runtime is currently tracked (for example after a + // clean process restart), restore the serving node instead of treating an + // agent request as permission to replace it with a client node. + if load_mesh_sharing_config(app)? + .is_some_and(|config| config.enabled && !config.model_id.trim().is_empty()) + { + restore_mesh_sharing(app, &state).await?; + return wait_for_mesh_inference(model_id).await; + } + let target = match resolve_mesh_bootstrap_target(&state, model_id).await { Ok(Some(target)) => target, Ok(None) => { @@ -768,15 +883,9 @@ pub(crate) async fn ensure_relay_mesh_for_record( } }; - // Serve→Client re-arm transition (micspiral review #3, intentional-by-design): - // if the dead ingress belonged to a *serve* node with running consumer - // agents, this re-arms it as a Client (`MeshNodeMode::Client`). That is the - // correct/safe recovery here — config-backed serve restoration is - // `restore_mesh_sharing`'s job (`MeshNodeMode::Serve`), and - // `ensure_client_node_for_model` reuses any live runtime of *either* mode - // (the router resolves per-request), so it only cold-starts a Client when - // there is genuinely no runtime. Falling back to Client if a serve node - // crashed under local pressure is a desirable fail-safe, not a regression. + // No serving configuration exists, so this is a genuine consumer-only + // start. A configured serving machine is restored above and never reaches + // this client fallback. ensure_client_node_for_model(&state, model_id, Some(target.endpoint_addr)).await?; wait_for_mesh_inference(model_id).await } @@ -792,14 +901,17 @@ pub async fn mesh_stop_node( // role under the lock and, when it's a consume session, leave it running // and return its live status unchanged. The frontend also guards this, but // status can be stale between polls, so the backend is authoritative. - let taken = { + let (taken, bound_relay_url) = { let mut guard = state.mesh_llm_runtime.lock().await; if let Some(runtime) = guard.as_ref() { if !share_stop_should_teardown(runtime.mode()) { return runtime.status().await.map_err(|error| error.to_string()); } } - guard.take() + let bound_relay_url = guard + .as_ref() + .and_then(|runtime| runtime.start_request().relay_url.clone()); + (guard.take(), bound_relay_url) }; if let Some(runtime) = taken { runtime.stop().await.map_err(|error| error.to_string())?; @@ -808,11 +920,13 @@ pub async fn mesh_stop_node( &app, &MeshSharingConfig { enabled: false, + start_on_next_launch: false, model_id: String::new(), max_vram_gb: None, + relay_url: None, }, )?; - mesh_llm::publish_stopped_status_once(&app, "stop").await; + mesh_llm::publish_stopped_status_once_at(&app, bound_relay_url.as_deref(), "stop").await; Ok(mesh_llm::stopped_status()) } diff --git a/desktop/src-tauri/src/commands/mesh_llm_tests.rs b/desktop/src-tauri/src/commands/mesh_llm_tests.rs index ccc5287d62..26eb1f5fba 100644 --- a/desktop/src-tauri/src/commands/mesh_llm_tests.rs +++ b/desktop/src-tauri/src/commands/mesh_llm_tests.rs @@ -110,6 +110,74 @@ fn buzz_mesh_name_is_stable_and_does_not_expose_the_relay() { assert!(!first.contains("example")); } +#[test] +fn sharing_config_keeps_the_community_where_sharing_was_enabled() { + let request = mesh_llm::StartMeshNodeRequest { + mode: mesh_llm::MeshNodeMode::Serve, + model_id: Some("test-model".to_string()), + max_vram_gb: Some(24), + join_token: None, + mesh_name: Some("buzz-community-test".to_string()), + relay_url: Some("wss://community.example".to_string()), + trusted_owner_ids: Some(Vec::new()), + }; + + let config = sharing_config_from_request(&request).expect("valid sharing config"); + assert_eq!(config.relay_url.as_deref(), Some("wss://community.example")); +} + +#[test] +fn legacy_sharing_config_without_community_binding_still_loads() { + let config: MeshSharingConfig = serde_json::from_value(serde_json::json!({ + "enabled": true, + "modelId": "test-model", + "maxVramGb": null + })) + .expect("legacy sharing config"); + + assert_eq!(config.relay_url, None); + assert!(!config.start_on_next_launch); +} + +#[test] +fn new_start_checkpoint_prevents_incomplete_download_restore() { + let config = MeshSharingConfig { + enabled: true, + start_on_next_launch: false, + model_id: "test-model".to_string(), + max_vram_gb: Some(24), + relay_url: Some("wss://community.example".to_string()), + }; + + let checkpoint = pending_new_start_checkpoint(&config); + assert!(!checkpoint.enabled); + assert!(!checkpoint.start_on_next_launch); + assert_eq!(checkpoint.model_id, config.model_id); + assert_eq!(checkpoint.max_vram_gb, config.max_vram_gb); + assert_eq!(checkpoint.relay_url, config.relay_url); +} + +#[test] +fn role_switch_checkpoint_starts_exactly_once_after_restart() { + let config = MeshSharingConfig { + enabled: true, + start_on_next_launch: false, + model_id: "test-model".to_string(), + max_vram_gb: Some(24), + relay_url: Some("wss://community.example".to_string()), + }; + + let restart = one_shot_restart_checkpoint(&config); + assert!(!restart.enabled); + assert!(restart.start_on_next_launch); + + let consumed = pending_new_start_checkpoint(&restart); + assert!(!consumed.enabled); + assert!(!consumed.start_on_next_launch); + assert_eq!(consumed.model_id, config.model_id); + assert_eq!(consumed.relay_url, config.relay_url); +} + #[test] fn readiness_failure_is_catalog_sync_when_model_never_visible() { assert_eq!( @@ -345,6 +413,7 @@ fn ensure_serve_runtime_serves_other_model() { max_vram_gb: None, join_token: None, mesh_name: None, + relay_url: None, trusted_owner_ids: None, }) .await diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 7a6f5b094a..4da889e667 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -42,15 +42,66 @@ pub fn apply_relay_mesh_env( RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV.to_string(), "1".to_string(), ); - // Keep the requested response inside smaller local-model context windows, - // and spend that budget on an answer/tool call instead of hidden reasoning. - // Without both settings Qwen3 either fails the router's fit check at the - // agent default (32K) or can consume a tight cap before serializing a tool. + // Shared compute runs small local models, which reliably finish a + // multi-step turn by writing the answer as prose instead of calling + // `send_message` — the reply is then never published. Opt this preset into + // the harness fallback that delivers that text. Mesh-only: cloud models + // publish their own replies, where the fallback would double-post. + // Value is `true`, not `1`: these are clap bool flags, which reject `1`. env.insert( - "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), - "4096".to_string(), + "BUZZ_ACP_DELIVER_PLAIN_REPLIES".to_string(), + "true".to_string(), ); - env.insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "none".to_string()); + // Offer the typed `send_message` reply tool here too. Small local models + // deliver replies far more reliably with it than by composing a + // `buzz messages send` shell command; scoped to this preset because adding + // a tool changes the toolset — and so the prompt — that every agent sees. + env.insert("BUZZ_ACP_SEND_MESSAGE_TOOL".to_string(), "true".to_string()); + // Keep the requested response inside smaller local-model context windows. + // These are defaults, not policy: the effective agent/persona/global env + // may deliberately choose a smaller cap or a different effort. This function + // runs after those layers during readiness, so never clobber their values. + insert_default_if_unset(env, "BUZZ_AGENT_MAX_OUTPUT_TOKENS", "4096"); + // Deliberately no BUZZ_AGENT_THINKING_EFFORT default: mesh translates + // `reasoning_effort` into the chat template's `enable_thinking` flag, so any + // value we pick overrides each model's own template default — and the right + // value is model-specific. Measured with the real prompt and toolset: + // gemma-4-E4B delivers 0/8 at `none` but 6/6 with the field absent, while + // Qwen3-8B delivers 8/8 either way and burns ~4x the output tokens once + // thinking is on (121 -> ~470), risking the 4096 cap. Omitting the field + // lets every model use its own default; explicit agent/persona/global + // values still apply. +} + +#[cfg(feature = "mesh-llm")] +fn insert_default_if_unset( + env: &mut std::collections::BTreeMap, + key: &str, + value: &str, +) { + if env.get(key).is_none_or(|current| current.trim().is_empty()) { + env.insert(key.to_string(), value.to_string()); + } +} + +/// Build the final Mesh-specific process overrides from the already-resolved +/// harness environment. Only user-owned generation controls are seeded: the +/// derived provider/base URL/model values remain authoritative, and unrelated +/// credentials (notably `OPENAI_API_KEY`) must not be copied back after the +/// spawn path removes them. +#[cfg(feature = "mesh-llm")] +pub fn relay_mesh_process_env( + effective_env: &std::collections::BTreeMap, + model: &str, +) -> std::collections::BTreeMap { + let mut env = std::collections::BTreeMap::new(); + for key in ["BUZZ_AGENT_MAX_OUTPUT_TOKENS", "BUZZ_AGENT_THINKING_EFFORT"] { + if let Some(value) = effective_env.get(key) { + env.insert(key.to_string(), value.clone()); + } + } + apply_relay_mesh_env(&mut env, Some(RELAY_MESH_PROVIDER_ID), Some(model)); + env } #[cfg(all(test, feature = "mesh-llm"))] @@ -60,7 +111,7 @@ mod tests { use super::*; #[test] - fn native_provider_uses_context_safe_non_reasoning_budget() { + fn native_provider_uses_context_safe_tool_calling_budget() { let mut env = BTreeMap::new(); apply_relay_mesh_env( &mut env, @@ -72,14 +123,89 @@ mod tests { env.get("BUZZ_AGENT_MAX_OUTPUT_TOKENS").map(String::as_str), Some("4096") ); - assert_eq!( - env.get("BUZZ_AGENT_THINKING_EFFORT").map(String::as_str), - Some("none") - ); + // Must stay unset: any value we pick overrides the model's own chat + // template default, and the right value is model-specific ("none" + // stops gemma tool-calling; enabling thinking makes Qwen3 burn ~4x the + // output budget). + assert_eq!(env.get("BUZZ_AGENT_THINKING_EFFORT"), None); assert_eq!( env.get(RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV) .map(String::as_str), Some("1") ); + // Small local models end multi-step turns with prose instead of a + // `send_message` call, so shared compute opts into harness delivery. + assert_eq!( + env.get("BUZZ_ACP_DELIVER_PLAIN_REPLIES") + .map(String::as_str), + // `true`, not `1`: clap bool flags reject `1` and the harness + // exits at startup with "invalid value '1'". + Some("true") + ); + // Typed reply tool: measured 3/8 -> 8/8 delivery on gemma-4-E4B. + assert_eq!( + env.get("BUZZ_ACP_SEND_MESSAGE_TOOL").map(String::as_str), + Some("true") + ); + } + + #[test] + fn non_mesh_provider_does_not_opt_into_plain_reply_delivery() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env(&mut env, Some("anthropic"), Some("claude-sonnet-4")); + + // Cloud models publish their own replies; delivering streamed text + // there would double-post. + assert_eq!(env.get("BUZZ_ACP_DELIVER_PLAIN_REPLIES"), None); + // And their toolset must stay exactly as it was before this PR. + assert_eq!(env.get("BUZZ_ACP_SEND_MESSAGE_TOOL"), None); + } + + #[test] + fn native_provider_preserves_explicit_generation_controls() { + let mut env = BTreeMap::from([ + ( + "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), + "2048".to_string(), + ), + ("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()), + ]); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + assert_eq!( + env.get("BUZZ_AGENT_MAX_OUTPUT_TOKENS").map(String::as_str), + Some("2048") + ); + assert_eq!( + env.get("BUZZ_AGENT_THINKING_EFFORT").map(String::as_str), + Some("high") + ); + } + + #[test] + fn process_env_seeds_controls_without_restoring_unrelated_credentials() { + let effective_env = BTreeMap::from([ + ( + "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), + "1024".to_string(), + ), + ("OPENAI_API_KEY".to_string(), "must-not-leak".to_string()), + ]); + + let env = relay_mesh_process_env(&effective_env, "Gemma-4"); + + assert_eq!( + env.get("BUZZ_AGENT_MAX_OUTPUT_TOKENS").map(String::as_str), + Some("1024") + ); + assert_eq!( + env.get("OPENAI_COMPAT_MODEL").map(String::as_str), + Some("Gemma-4") + ); + assert!(!env.contains_key("OPENAI_API_KEY")); } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..37927961ed 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -869,12 +869,7 @@ pub fn spawn_agent_child( // uses the same trim semantics as the preflight callers. #[cfg(feature = "mesh-llm")] if let Some(ref mesh_model_id) = mesh_model_id { - let mut mesh_env = std::collections::BTreeMap::new(); - super::apply_relay_mesh_env( - &mut mesh_env, - Some(super::RELAY_MESH_PROVIDER_ID), - Some(mesh_model_id.as_str()), - ); + let mesh_env = super::relay_mesh_process_env(&descriptor.env, mesh_model_id); command.env_remove("OPENAI_API_KEY"); for (key, value) in mesh_env { command.env(key, value); diff --git a/desktop/src-tauri/src/mesh_llm/catalog.rs b/desktop/src-tauri/src/mesh_llm/catalog.rs index 385971cb86..1a11fcfcd1 100644 --- a/desktop/src-tauri/src/mesh_llm/catalog.rs +++ b/desktop/src-tauri/src/mesh_llm/catalog.rs @@ -19,12 +19,14 @@ use mesh_llm_system::vram::{format_rated_capacity, rated_capacity_gb}; /// The large pick is resolved through mesh-llm's remote catalog /// (huggingface.co/datasets/meshllm/catalog), so it does not need to exist in /// the compiled `MODEL_CATALOG`; the entry is synthesized below. -const CURATED_LARGE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M"; +const CURATED_LARGE: &str = "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"; +const CURATED_LARGE_ALIAS: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M"; const CURATED_LARGE_SIZE: &str = "17GB"; const CURATED_LARGE_FILE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf"; const CURATED_LARGE_DESCRIPTION: &str = "Gemma 4 26B MoE (4B active) — Buzz default for 64GB+ machines"; -const CURATED_SMALL: &str = "Gemma-4-E4B-it-Q4_K_M"; +const CURATED_SMALL: &str = "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"; +const CURATED_SMALL_ALIAS: &str = "Gemma-4-E4B-it-Q4_K_M"; /// Rated-capacity boundary between the two curated tiers, in GB (marketing /// capacity — a "64GB" Mac rates as 64 even though usable AI memory is less). const CURATED_LARGE_MIN_RATED_GB: u64 = 64; @@ -37,6 +39,16 @@ fn buzz_recommended_model(rated_gb: Option) -> &'static str { } } +/// Convert Buzz's pre-0.74 curated package aliases into the canonical model +/// ids advertised and accepted by Mesh's OpenAI ingress. +pub(crate) fn canonical_curated_model_id(model_id: &str) -> &str { + match model_id.trim() { + CURATED_SMALL_ALIAS => CURATED_SMALL, + CURATED_LARGE_ALIAS => CURATED_LARGE, + other => other, + } +} + /// How a model sits inside this machine's usable AI memory. /// Mirrors mesh-llm's private `fit_code_for_size_label` thresholds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -146,12 +158,13 @@ fn build_catalog( .filter(|m| !is_draft_only(&m.name)) .map(|m| { let size_gb = parse_size_gb(&m.size); + let name = canonical_curated_model_id(&m.name).to_string(); MeshCatalogEntry { fit: fit_code(size_gb, vram_gb), - installed: is_installed(&m.file, &m.name), + installed: is_installed(&m.file, &name) || is_installed(&m.file, &m.name), recommended: false, curated: false, - name: m.name.clone(), + name, size: m.size.clone(), size_gb, description: m.description.clone(), @@ -166,7 +179,8 @@ fn build_catalog( let size_gb = parse_size_gb(CURATED_LARGE_SIZE); entries.push(MeshCatalogEntry { fit: fit_code(size_gb, vram_gb), - installed: is_installed(CURATED_LARGE_FILE, CURATED_LARGE), + installed: is_installed(CURATED_LARGE_FILE, CURATED_LARGE) + || is_installed(CURATED_LARGE_FILE, CURATED_LARGE_ALIAS), recommended: false, curated: false, name: CURATED_LARGE.to_string(), @@ -256,6 +270,8 @@ mod tests { #[test] fn recommendation_follows_buzz_curated_tiers() { + assert_eq!(CURATED_SMALL, "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"); + assert_eq!(CURATED_LARGE, "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"); // 64GB+ rated machines get the large curated pick. let large = build_catalog(None, 64_000_000_000, 64.0, &[]); assert_eq!(large.recommended.as_deref(), Some(CURATED_LARGE)); @@ -269,6 +285,22 @@ mod tests { assert_eq!(tiny.recommended.as_deref(), Some(CURATED_SMALL)); } + #[test] + fn curated_package_aliases_migrate_to_openai_model_ids() { + assert_eq!( + canonical_curated_model_id(CURATED_SMALL_ALIAS), + CURATED_SMALL + ); + assert_eq!( + canonical_curated_model_id(CURATED_LARGE_ALIAS), + CURATED_LARGE + ); + assert_eq!( + canonical_curated_model_id("other/model:Q4"), + "other/model:Q4" + ); + } + #[test] fn curated_picks_lead_the_catalog() { let catalog = build_catalog(None, 96_000_000_000, 96.0, &[]); diff --git a/desktop/src-tauri/src/mesh_llm/coordinator.rs b/desktop/src-tauri/src/mesh_llm/coordinator.rs index 1e279353b6..066fa46373 100644 --- a/desktop/src-tauri/src/mesh_llm/coordinator.rs +++ b/desktop/src-tauri/src/mesh_llm/coordinator.rs @@ -132,7 +132,7 @@ pub async fn start_coordinator(app: AppHandle) { /// MeshLLM establishes the encrypted peer transport itself. async fn reconcile_buzz_mesh_join(app: &AppHandle) -> Result<(), String> { let state = app.state::(); - let peer_ids = { + let (peer_ids, relay_url) = { let runtime = state.mesh_llm_runtime.lock().await; let Some(runtime) = runtime.as_ref() else { return Ok(()); @@ -141,10 +141,16 @@ async fn reconcile_buzz_mesh_join(app: &AppHandle) -> Result<(), String> { .status_report_payload() .await .map_err(|error| error.to_string())?; - visible_peer_ids(&payload) + let relay_url = runtime + .start_request() + .relay_url + .clone() + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(&state)); + (visible_peer_ids(&payload), relay_url) }; - let targets = crate::commands::mesh_llm::resolve_buzz_mesh_join_targets(&state).await?; + let targets = + crate::commands::mesh_llm::resolve_buzz_mesh_join_targets_at(&state, &relay_url).await?; let Some(target) = targets .into_iter() .find(|target| !target_is_visible(target, &peer_ids)) @@ -201,8 +207,13 @@ fn target_is_visible(target: &crate::mesh_llm::MeshServeTarget, peer_ids: &[Stri enum RosterReconcileAction { /// Keep the running allowlist untouched (no-op, or a failure we ride out). Keep, - /// Restart the node with a freshly resolved roster. - Restart(Vec), + /// Restart Buzz so MeshLLM is rebuilt with a freshly resolved roster. + /// + /// MeshLLM's native listeners are process-owned in practice: stopping and + /// starting the embedded runtime in one process can terminate Buzz or race + /// ports 9337/3131. The process boundary is therefore part of the safety + /// contract, not an implementation detail. + RestartProcess, /// Observed a *shrink* (or empty) once. Hold the current allowlist and /// require the same reduced roster on the next poll before tearing down, /// so a single transient short-read never drops a member mid-inference. @@ -224,9 +235,9 @@ fn roster_shrinks(current: &[String], fresh: &[String]) -> bool { /// Rules: /// - query failed (`Err`) → `Keep` (never de-admit on a relay blip) /// - resolved roster == current → `Keep` (no-op) -/// - grows (only additions) → `Restart` immediately (fast admission) +/// - grows (only additions) → `RestartProcess` immediately (fast admission) /// - shrinks/empties, first observation → `AwaitConfirm` (hold, re-check next poll) -/// - shrinks/empties, confirmed → `Restart` (same reduced roster twice) +/// - shrinks/empties, confirmed → `RestartProcess` (same reduced roster twice) fn roster_reconcile_action( current_owners: &[String], pending_shrink: Option<&[String]>, @@ -248,13 +259,13 @@ fn roster_reconcile_action( // Growth (pure additions) is safe to apply immediately. if !roster_shrinks(current_owners, &fresh) { - return RosterReconcileAction::Restart(fresh); + return RosterReconcileAction::RestartProcess; } // A shrink (including down to empty) must be confirmed across two // consecutive polls with the *same* reduced roster before we tear down. match pending_shrink { - Some(pending) if pending == fresh => RosterReconcileAction::Restart(fresh), + Some(pending) if pending == fresh => RosterReconcileAction::RestartProcess, _ => RosterReconcileAction::AwaitConfirm(fresh), } } @@ -283,8 +294,13 @@ async fn reconcile_roster( // other member on a transient relay blip (the flapping restart loop). Keep // the current allowlist and try again on the next poll. A shrink is held // for one extra poll (hysteresis) so a single short-read never tears down. - let query = crate::commands::mesh_llm::resolve_trusted_owner_ids(&state).await; - let fresh = match roster_reconcile_action(current_owners, pending_shrink.as_deref(), query) { + let relay_url = current_request + .relay_url + .as_deref() + .map(str::to_owned) + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(&state)); + let query = crate::commands::mesh_llm::resolve_trusted_owner_ids_at(&state, &relay_url).await; + match roster_reconcile_action(current_owners, pending_shrink.as_deref(), query) { RosterReconcileAction::Keep => { *pending_shrink = None; return Ok(()); @@ -294,34 +310,12 @@ async fn reconcile_roster( *pending_shrink = Some(reduced); return Ok(()); } - RosterReconcileAction::Restart(fresh) => { + RosterReconcileAction::RestartProcess => { *pending_shrink = None; - fresh } - }; + } - let mut request = current_request.clone(); - request.trusted_owner_ids = Some(fresh); - // Bootstrap endpoints are live device state, not configuration. The - // endpoint used at the previous start may belong to the member that just - // left or to a device whose iroh identity rotated while offline. Resolve a - // fresh validated peer for this restart; starting isolated is safe because - // the join watcher will converge it when a member next publishes. - request.join_token = match crate::commands::mesh_llm::resolve_buzz_mesh_join_targets(&state) - .await - { - Ok(targets) => targets - .into_iter() - .next() - .map(|target| target.endpoint_addr), - Err(error) => { - eprintln!( - "buzz-mesh: could not refresh bootstrap endpoint for roster restart; starting isolated: {error}" - ); - None - } - }; - let mut guard = state.mesh_llm_runtime.lock().await; + let guard = state.mesh_llm_runtime.lock().await; let startup_pending = match guard.as_ref() { Some(runtime) => runtime.is_starting().await, None => false, @@ -342,24 +336,14 @@ async fn reconcile_roster( // snapshot. return Ok(()); } - let Some(running) = guard.take() else { + if guard.is_none() { return Ok(()); - }; - eprintln!("buzz-mesh: membership roster changed; restarting mesh node with fresh allowlist"); - if let Err(error) = running.stop().await { - drop(guard); - eprintln!( - "buzz-mesh: stopping mesh node for roster restart failed; restarting Buzz instead of racing the occupied ingress: {error}" - ); - app.request_restart(); - return Err(format!( - "mesh node shutdown failed during roster change: {error}" - )); } - let replacement = crate::mesh_llm::DesktopMeshRuntime::start(request) - .await - .map_err(|error| format!("mesh node restart after roster change failed: {error:#}"))?; - *guard = Some(replacement); + drop(guard); + eprintln!( + "buzz-mesh: membership roster changed; restarting Buzz to rebuild MeshLLM with the fresh community allowlist" + ); + app.request_restart(); Ok(()) } @@ -377,11 +361,15 @@ pub(crate) async fn publish_current_status_once(app: &AppHandle, reason: &str) { } } -pub(crate) async fn publish_stopped_status_once(app: &AppHandle, reason: &str) { +pub(crate) async fn publish_stopped_status_once_at( + app: &AppHandle, + relay_url: Option<&str>, + reason: &str, +) { let state = app.state::(); match tokio::time::timeout( STATUS_PUBLISH_TIMEOUT, - publish_stopped_status_for_state(&state), + publish_stopped_status_for_state(&state, relay_url), ) .await { @@ -396,26 +384,43 @@ pub(crate) async fn publish_stopped_status_once(app: &AppHandle, reason: &str) { async fn publish_current_status_for_state(state: &AppState) -> Result<(), String> { let identity = super::ensure_owner_identity() .map_err(|error| format!("failed to load mesh owner identity: {error}"))?; - let mut payload = { + let (mut payload, relay_url) = { let runtime = state.mesh_llm_runtime.lock().await; match runtime.as_ref() { - Some(runtime) => runtime - .status_report_payload() - .await - .map_err(|error| error.to_string())?, - None => stopped_status_payload(&identity), + Some(runtime) => { + let payload = runtime + .status_report_payload() + .await + .map_err(|error| error.to_string())?; + let relay_url = runtime + .start_request() + .relay_url + .clone() + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(state)); + (payload, relay_url) + } + None => ( + stopped_status_payload(&identity), + crate::relay::relay_ws_url_with_override(state), + ), } }; bind_payload_to_member(state, &identity, &mut payload)?; - publish_status_report(state, payload).await + publish_status_report_at(state, &relay_url, payload).await } -async fn publish_stopped_status_for_state(state: &AppState) -> Result<(), String> { +async fn publish_stopped_status_for_state( + state: &AppState, + relay_url: Option<&str>, +) -> Result<(), String> { let identity = super::ensure_owner_identity() .map_err(|error| format!("failed to load mesh owner identity: {error}"))?; let mut payload = stopped_status_payload(&identity); bind_payload_to_member(state, &identity, &mut payload)?; - publish_status_report(state, payload).await + let relay_url = relay_url + .map(str::to_owned) + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(state)); + publish_status_report_at(state, &relay_url, payload).await } fn stopped_status_payload(identity: &super::identity::OwnerIdentity) -> serde_json::Value { @@ -469,13 +474,21 @@ pub(crate) fn build_status_report_event( .tags([d, k])) } -pub(crate) async fn publish_status_report( +async fn publish_status_report_at( state: &AppState, + relay_url: &str, payload: serde_json::Value, ) -> Result<(), String> { - crate::relay::submit_event(build_status_report_event(payload)?, state) - .await - .map(|_| ()) + let api_base_url = crate::relay::relay_http_base_url(relay_url); + let keys = state.signing_keys()?; + crate::relay::submit_event_at_with_keys( + build_status_report_event(payload)?, + state, + &api_base_url, + &keys, + ) + .await + .map(|_| ()) } #[cfg(test)] @@ -554,11 +567,11 @@ mod tests { // Growth (pure additions) applies immediately — fast admission is fine. #[test] - fn roster_growth_restarts_immediately() { + fn roster_growth_requests_process_restart_immediately() { let current = vec!["owner-a".to_string()]; let fresh = vec!["owner-a".to_string(), "owner-c".to_string()]; - let action = roster_reconcile_action(¤t, None, Ok(fresh.clone())); - assert_eq!(action, RosterReconcileAction::Restart(fresh)); + let action = roster_reconcile_action(¤t, None, Ok(fresh)); + assert_eq!(action, RosterReconcileAction::RestartProcess); } // A shrink is NOT applied on first observation — it must be confirmed. @@ -572,11 +585,11 @@ mod tests { // The same reduced roster on two consecutive polls confirms the shrink. #[test] - fn roster_shrink_restarts_once_confirmed() { + fn roster_shrink_requests_process_restart_once_confirmed() { let current = vec!["owner-a".to_string(), "owner-b".to_string()]; let reduced = vec!["owner-a".to_string()]; let action = roster_reconcile_action(¤t, Some(&reduced), Ok(reduced.clone())); - assert_eq!(action, RosterReconcileAction::Restart(reduced)); + assert_eq!(action, RosterReconcileAction::RestartProcess); } // A shrink that changes between polls is not confirmed — it re-holds with @@ -600,7 +613,7 @@ mod tests { assert_eq!(first, RosterReconcileAction::AwaitConfirm(Vec::new())); let empty: Vec = Vec::new(); let confirmed = roster_reconcile_action(¤t, Some(&empty), Ok(Vec::new())); - assert_eq!(confirmed, RosterReconcileAction::Restart(Vec::new())); + assert_eq!(confirmed, RosterReconcileAction::RestartProcess); } // A shrink followed by recovery to the full roster cancels the teardown. diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index 6e3ab4b28b..e206c53886 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; mod coordinator; -pub(crate) use coordinator::{publish_current_status_once, publish_stopped_status_once}; +pub(crate) use coordinator::{publish_current_status_once, publish_stopped_status_once_at}; pub use coordinator::{start_coordinator, MeshCoordinator, KIND_BUZZ_MESH_MEMBER_STATUS}; mod discovery; @@ -14,6 +14,7 @@ pub(crate) use discovery::{ use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity}; mod catalog; +pub(crate) use catalog::canonical_curated_model_id; pub use catalog::{model_catalog, MeshModelCatalog}; mod identity; @@ -200,6 +201,11 @@ pub struct StartMeshNodeRequest { /// accepted from the frontend and contains no relay address. #[serde(default, skip_deserializing)] pub mesh_name: Option, + /// Relay this runtime's community membership and discovery are bound to. + /// Injected by the backend when sharing starts and retained across UI + /// workspace switches; moving a share requires an explicit stop/start. + #[serde(default, skip_deserializing)] + pub relay_url: Option, /// Mesh owner ids admitted to this node (the member roster from /// member-signed discovery notes). `None` = caller did not resolve a roster /// (tests, direct invocations): the node runs without allowlist @@ -308,17 +314,20 @@ pub const MESH_WORKER_STACK_SIZE: usize = 8 * 1024 * 1024; /// before the node starts. Without this the download happens *inside* /// `serve::start()` where the UI can only show a frozen "starting…" state. /// Already-installed models return immediately from the cache scan. -async fn ensure_model_downloaded(model: &str) -> anyhow::Result<()> { - let model_owned = model.to_string(); - let installed = tokio::task::spawn_blocking(move || { +async fn model_is_installed(model: &str) -> bool { + let model_owned = model.replace("@main", ""); + tokio::task::spawn_blocking(move || { let cache = mesh_llm_node::models::default_huggingface_cache_dir(); mesh_llm_node::models::scan_installed_models(cache) .iter() - .any(|m| m.model_ref.contains(&model_owned)) + .any(|m| m.model_ref.replace("@main", "").contains(&model_owned)) }) .await - .unwrap_or(false); - if installed { + .unwrap_or(false) +} + +async fn ensure_model_downloaded(model: &str) -> anyhow::Result<()> { + if model_is_installed(model).await { return Ok(()); } mesh_llm_host_runtime::models::download_model_ref_with_progress_details(model, true) diff --git a/desktop/src-tauri/src/mesh_llm/mod_tests.rs b/desktop/src-tauri/src/mesh_llm/mod_tests.rs index 0b726c264f..557cd040fa 100644 --- a/desktop/src-tauri/src/mesh_llm/mod_tests.rs +++ b/desktop/src-tauri/src/mesh_llm/mod_tests.rs @@ -12,6 +12,7 @@ fn pending_client_runtime( max_vram_gb: None, join_token: Some("initial-token".to_string()), mesh_name: None, + relay_url: None, trusted_owner_ids: None, }; super::DesktopMeshRuntime { diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 809fab8993..89ca6396e9 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -153,6 +153,13 @@ fn should_evict_after_probe( || consecutive >= DEAD_PROBE_EVICT_THRESHOLD } +fn requires_process_restart( + mode: crate::mesh_llm::MeshNodeMode, + startup_in_progress: bool, +) -> bool { + startup_in_progress || mode == crate::mesh_llm::MeshNodeMode::Serve +} + /// Probe and, when justified, remove one stale runtime. A closed port is /// decisive for a foreground agent start; watchdog and ambiguous/unhealthy /// ports require consecutive failures to avoid restarting on a transient load @@ -161,22 +168,23 @@ pub(crate) async fn recover_stale_mesh_runtime( state: &AppState, urgency: MeshRecoveryUrgency, ) -> MeshRuntimeRecovery { - let (candidate_id, startup_in_progress) = match state.mesh_llm_runtime.lock().await.as_ref() { - Some(runtime) => (runtime.id(), runtime.is_starting().await), - None => { - state.mesh_recovery.reset_probe_streak(); - // A cancelled SDK startup can outlive its Buzz-side task briefly - // because the embedded runtime runs on its own thread. Never start - // a replacement merely because the tracked handle is gone: first - // prove the old ingress is either still useful or has released the - // port. This closes the port-conflict loop in #2304. - return match probe_mesh_ingress().await { - MeshIngressProbe::Live => MeshRuntimeRecovery::Live, - MeshIngressProbe::PortClosed => MeshRuntimeRecovery::Absent, - MeshIngressProbe::Unhealthy => MeshRuntimeRecovery::ReleasePending, - }; - } - }; + let (candidate_id, startup_in_progress, candidate_mode) = + match state.mesh_llm_runtime.lock().await.as_ref() { + Some(runtime) => (runtime.id(), runtime.is_starting().await, runtime.mode()), + None => { + state.mesh_recovery.reset_probe_streak(); + // A cancelled SDK startup can outlive its Buzz-side task briefly + // because the embedded runtime runs on its own thread. Never start + // a replacement merely because the tracked handle is gone: first + // prove the old ingress is either still useful or has released the + // port. This closes the port-conflict loop in #2304. + return match probe_mesh_ingress().await { + MeshIngressProbe::Live => MeshRuntimeRecovery::Live, + MeshIngressProbe::PortClosed => MeshRuntimeRecovery::Absent, + MeshIngressProbe::Unhealthy => MeshRuntimeRecovery::ReleasePending, + }; + } + }; let probe = probe_mesh_ingress().await; if probe == MeshIngressProbe::Live { state.mesh_recovery.reset_probe_streak(); @@ -196,12 +204,13 @@ pub(crate) async fn recover_stale_mesh_runtime( return MeshRuntimeRecovery::Debouncing; } - // The pinned SDK does not yield its control handle until the management - // API is ready. Dropping its still-pending start future would detach the - // embedded runtime thread without sending a shutdown request, so Buzz must - // not evict it and race a replacement onto the same ports. A controlled - // app relaunch is the only process-owned cleanup boundary in this state. - if startup_in_progress { + // Never replace a serving runtime in-process. Its native listeners and + // model host are process-owned; stopping it here and then cold-starting a + // client silently disables Share Compute and can race ports 9337/3131. + // Pending client startups have the same ownership problem because the SDK + // has not yielded a shutdown handle yet. In both cases, process restart is + // the only boundary that preserves the configured role safely. + if requires_process_restart(candidate_mode, startup_in_progress) { state.mesh_recovery.reset_probe_streak(); return MeshRuntimeRecovery::RestartRequired; } @@ -241,6 +250,12 @@ pub(crate) async fn recover_stale_mesh_runtime( pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Result<(), String> { let state = app.state::(); let _rearm_guard = state.mesh_recovery.rearm_lock.lock().await; + let runtime_mode = state + .mesh_llm_runtime + .lock() + .await + .as_ref() + .map(|runtime| runtime.mode()); let recovery = recover_stale_mesh_runtime(&state, MeshRecoveryUrgency::Watchdog).await; let active_pubkeys = active_managed_agent_pubkeys(&state); // Mesh participation is resolved through the same definition-authoritative @@ -254,6 +269,13 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu | MeshRuntimeRecovery::Debouncing | MeshRuntimeRecovery::Replaced => return Ok(()), MeshRuntimeRecovery::RestartRequired => { + if runtime_mode == Some(crate::mesh_llm::MeshNodeMode::Serve) { + eprintln!( + "buzz-mesh: serving ingress failed; restarting Buzz to restore Share Compute without changing roles" + ); + app.request_restart(); + return Ok(()); + } let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); if !records.iter().any(|record| { running_relay_mesh_model_id(record, &active_pubkeys, &personas, &global).is_some() @@ -410,6 +432,7 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, catalog_source: None, @@ -481,6 +504,22 @@ mod tests { assert!(STALE_STOP_TIMEOUT <= Duration::from_secs(15)); } + #[test] + fn failed_serving_runtime_requires_process_restart_instead_of_client_fallback() { + assert!(requires_process_restart( + crate::mesh_llm::MeshNodeMode::Serve, + false + )); + assert!(requires_process_restart( + crate::mesh_llm::MeshNodeMode::Client, + true + )); + assert!(!requires_process_restart( + crate::mesh_llm::MeshNodeMode::Client, + false + )); + } + #[test] fn only_running_relay_mesh_agents_trigger_rearm() { let personas: Vec = Vec::new(); diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index 76f86f8054..fd7550eff0 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -107,7 +107,9 @@ export function MeshComputeSettingsCard() { // One-shot hardware-aware catalog fetch. Purely additive: when it fails // (stub build, survey error) the card falls back to the free-text field. - // Keep an empty draft empty so the UI can explicitly ask the member to choose. + // When there is no saved choice, make the curated recommendation the actual + // default so a new member can turn Share Compute on directly. An explicit + // saved draft always wins. React.useEffect(() => { let cancelled = false; (async () => { @@ -115,6 +117,11 @@ export function MeshComputeSettingsCard() { const value = await meshModelCatalog(); if (cancelled) return; setCatalog(value); + setModelInput((current) => { + if (current.trim() !== "" || !value.recommended) return current; + writeDraft(MODEL_DRAFT_STORAGE_KEY, value.recommended); + return value.recommended; + }); } catch { // Non-fatal — picker just doesn't render. } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4cfc553df1..e9ed438086 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2888,9 +2888,7 @@ const mockMeshState: { servingUsage: MockServingUsage; } = { admitted: true, - models: [ - { id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M", name: "SmolLM2 135M" }, - ], + models: [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }], denyReason: "not a relay member", nodeState: "off", nodeMode: null, @@ -2899,9 +2897,7 @@ const mockMeshState: { function resetMockMesh() { mockMeshState.admitted = true; - mockMeshState.models = [ - { id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M", name: "SmolLM2 135M" }, - ]; + mockMeshState.models = [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }]; mockMeshState.denyReason = "not a relay member"; mockMeshState.nodeState = "off"; mockMeshState.nodeMode = null; @@ -9737,6 +9733,25 @@ export function maybeInstallE2eTauriMocks() { } case "mesh_installed_models": return mockMeshState.models; + case "mesh_model_catalog": + return { + gpuName: "Mock Apple GPU", + vramDisplay: "32 GB", + vramGb: 32, + recommended: "Gemma-4-E4B-it-Q4_K_M", + entries: [ + { + name: "Gemma-4-E4B-it-Q4_K_M", + size: "3.5GB", + sizeGb: 3.5, + description: "Buzz-curated local agent model", + fit: "comfortable", + installed: true, + recommended: true, + curated: true, + }, + ], + }; case "mesh_node_status": return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode); case "mesh_serving_usage": diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index 7c360368a2..b2e8fc28a9 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -15,7 +15,7 @@ type E2eWindow = Window & { }) => void; }; -test("Share compute has a clear empty state and starts and stops sharing", async ({ +test("Share compute selects the curated default and starts and stops sharing", async ({ page, }) => { await installMockBridge(page); @@ -30,22 +30,32 @@ test("Share compute has a clear empty state and starts and stops sharing", async await expect(card).toContainText( "Choose a suggested model below, or enter a model reference or local file", ); - await expect(toggle).toBeDisabled(); - - await model.fill("hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M"); + await expect(model).toHaveValue("Gemma-4-E4B-it-Q4_K_M"); + await expect(toggle).toBeEnabled(); await expect(card).toContainText( "Buzz downloads remote models when sharing starts", ); - await expect(toggle).toBeEnabled(); await toggle.click(); await expect(toggle).toBeChecked(); - await expect(card).toContainText("Sharing SmolLM2 135M with relay members"); + await expect(card).toContainText("Sharing Gemma 4 E4B with relay members"); await expect .poll(() => page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []), ) .toContain("mesh_start_node"); + await expect + .poll(() => + page.evaluate( + () => (window as E2eWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [], + ), + ) + .toContainEqual({ + command: "mesh_start_node", + payload: { + request: { mode: "serve", modelId: "Gemma-4-E4B-it-Q4_K_M" }, + }, + }); await toggle.click(); await expect(toggle).not.toBeChecked();