feat(mesh): '!ai' over mesh runs the assistant's shared tool loop

Mesh AssistQuery answered with a bare LLM call — no tools, no actions.
The CallerScope::Mesh variant was designed for this wiring ('the variant
exists so the shape is right when a future plan wires mesh callers into
the shared loop'); this is that plan. A trusted/allowlisted asker's prompt
now runs assistant::chat with CallerScope::Mesh { authorized } — the
operator's persisted grants cap what the model may touch (never wider),
and writes suspend on the node's own confirm gate. The reply is capped
for airtime as before, with a brevity instruction for mesh turns.

Wiring follows the blob_store pattern: RpcHandler::set_mesh_service (now
&Arc<Self>) forward-propagates an Arc<RpcHandler> into the mesh state's
new assistant_handler slot; absent (early boot) falls back to the legacy
bare-LLM answer.

Test: mesh_caller_authority_is_capped_at_operator_grants.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 16:54:12 -04:00
co-authored by Claude
parent 34085e30a2
commit 4361a5cba7
4 changed files with 81 additions and 5 deletions
+24 -4
View File
@@ -129,10 +129,30 @@ pub(super) async fn run_assist(
info!(from = asker, req_id, backend = %backend, model = %model, "Answering AI query over mesh");
let result = if is_claude {
call_claude(&state.data_dir, &model, &prompt).await
} else {
call_ollama(&model, &prompt).await
// Same tool surface as the embedded assistant (task: mesh `!ai` must
// ACTION, not just chat): when the server has wired the shared loop in,
// the asker's prompt runs through `assistant::chat` with a Mesh caller
// scope — the operator's persisted grants gate what the model may touch,
// and any write suspends on the node's confirm gate for the operator to
// approve. The asker passed `is_sender_allowed` above, so `authorized`
// is true here. Without the handler (early boot), the legacy bare-LLM
// path still answers.
let shared = state.assistant_handler.read().await.clone();
let result = match shared {
Some(handler) => {
let caller = crate::assistant::CallerScope::Mesh {
peer_id: asker.to_string(),
authorized: true,
};
crate::assistant::chat(handler, caller, prompt.clone()).await
}
None => {
if is_claude {
call_claude(&state.data_dir, &model, &prompt).await
} else {
call_ollama(&model, &prompt).await
}
}
};
match result {
@@ -223,6 +223,11 @@ pub struct MeshState {
/// by `RpcHandler` after startup so the mesh listener can persist inline
/// file bytes into the same store the HTTP layer serves.
pub blob_store: RwLock<Option<Arc<crate::blobs::BlobStore>>>,
/// The assistant's shared tool loop, reached through the same RpcHandler
/// every caller dispatches on. Populated by the server after startup
/// (same pattern as blob_store). `None` on early boot → the legacy
/// bare-LLM path in assist.rs answers instead.
pub assistant_handler: RwLock<Option<Arc<crate::api::rpc::RpcHandler>>>,
/// Firmware-pubkey-hex of radio contacts the user has chosen to ignore
/// (via mesh.clear-all). `refresh_contacts` skips any device contact
/// whose pubkey is in this set, preventing the meshcore firmware's
@@ -354,6 +359,7 @@ impl MeshState {
contacts: RwLock::new(HashMap::new()),
our_ed_pubkey_hex,
blob_store: RwLock::new(None),
assistant_handler: RwLock::new(None),
radio_contact_blocklist: RwLock::new(HashSet::new()),
assistant: RwLock::new(assistant),
data_dir,