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
+45
View File
@@ -944,6 +944,16 @@ pub async fn chat_with_surfaces(
tracing::info!(backend = %backend_id, "assistant.chat: backend selected for this turn");
let system_prompt = build_system_prompt(&visible_tools, &disabled_tools);
// Radio airtime is scarce: a mesh caller's answer must stay short.
let system_prompt = if matches!(caller, CallerScope::Mesh { .. }) {
format!(
"{system_prompt}\n\nThis question arrives over a low-bandwidth radio mesh. Reply \
in at most two short sentences, no markdown, no preamble — and if a write was \
confirmed and started, say only that it is underway."
)
} else {
system_prompt
};
let key = history::HistoryKey::from_caller(&caller);
@@ -1297,6 +1307,41 @@ mod tests {
);
}
/// Mesh callers (#50 wiring): an AUTHORIZED mesh peer inherits exactly
/// the operator's persisted grants — never more — and an unauthorized
/// one gets nothing, even when the operator has categories open.
#[tokio::test]
async fn mesh_caller_authority_is_capped_at_operator_grants() {
let (handler, _tmp) = test_rpc_handler().await;
// Open one category as the operator would.
let mut grants = crate::assistant::grants::Grants::load(handler.data_dir()).await;
grants.set(PermissionCategory::Apps, true);
grants
.save(handler.data_dir())
.await
.expect("persist test grants");
let authorized = CallerScope::Mesh {
peer_id: "peer-a".to_string(),
authorized: true,
};
let granted = authorized.granted_categories(handler.data_dir()).await;
assert!(granted.contains(&PermissionCategory::Apps));
assert!(!granted.contains(&PermissionCategory::Media));
let unauthorized = CallerScope::Mesh {
peer_id: "peer-b".to_string(),
authorized: false,
};
assert!(
unauthorized
.granted_categories(handler.data_dir())
.await
.is_empty(),
"an unauthorized mesh caller must resolve to zero grants"
);
}
/// The prompt's AVAILABLE section must list only granted-category tools.
/// Ungranted tools appear exclusively under DISABLED — listed so the
/// model's call attempt hits the execution gate and records the refusal