feat(13-01): Rust assistant spine — one curated tool, one backend, one RPC surface

D-01/D-02/D-06 tracer slice: a new crate::assistant module (CallerScope,
PermissionCategory, ToolExecCtx, chat()) runs a multi-turn tool-calling loop
(run_loop/execute_tool, MAX_TURNS=8) against a curated single-tool registry
(system_disk_status, hand-written JSON Schema — no schemars) via a Claude
Messages API backend. execute_tool is the single choke point: unknown tools
are refused not ignored, D-16 category grants are re-checked even though the
system prompt already omits ungranted tools, and every real tool dispatches
through the SAME handle_system_disk_status RPC handler every other
authenticated caller uses (assistant_dispatch_tool bridge in
api/rpc/assistant_chat.rs) — never an AI-only backdoor.

assistant.chat is registered in dispatcher.rs as a single guarded
`m if m.starts_with("assistant.")` arm reached only after the existing
session-cookie + CSRF + role.can_access() gate in api/rpc/mod.rs — asserted
directly by assistant_methods_require_session against the live
UNAUTHENTICATED_METHODS list (visibility only widened to pub(crate) for that
assertion; the list's contents are untouched, per the Phase-10 hard
constraint).

Key read from data_dir/secrets/claude-api-key — the same path
mesh/rpc/mesh/assistant.rs already probes — never a second key location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-03 14:37:16 -04:00
co-authored by Claude Opus 5
parent 15774d266f
commit fe6ccff73c
11 changed files with 924 additions and 2 deletions
+125
View File
@@ -0,0 +1,125 @@
//! D-02: "one assistant, many front doors." A shared assistant service —
//! one curated tool registry, one backend selector, one place the model key
//! lives — used today by AIUI chat (`CallerScope::LocalOperator`) and, by
//! design, extensible to mesh/LoRa callers (`CallerScope::Mesh`) and later
//! Pine voice without recreating a second, divergent security model.
//!
//! This is the tracer slice for Phase 13 (D-01, D-02, D-06): a typed
//! question reaches exactly one curated, read-only tool
//! (`tools::system_disk_status_tool`) via the Claude backend, dispatched
//! through the SAME `handle_system_disk_status` RPC handler every other
//! authenticated caller uses. See `13-01-PLAN.md` for the full spine.
pub mod backends;
pub mod loop_;
pub mod tools;
use std::collections::BTreeSet;
use std::sync::Arc;
use anyhow::Result;
use crate::api::rpc::RpcHandler;
/// D-16's ten permission categories. All default-closed on a fresh node —
/// nothing is shared with the model until deliberately granted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionCategory {
Apps,
System,
Network,
Wallet,
Files,
Media,
Search,
AiLocal,
Notes,
Bitcoin,
}
/// D-02's promoted primary noun: a caller identity carrying the permission
/// scope its tool calls resolve authority through. "A mesh peer" and "the
/// local operator in AIUI" are two variants of it; Pine voice will be a
/// third (not built in this phase — no `Voice` variant exists yet, by
/// design, until that phase actually needs one).
#[derive(Debug, Clone)]
pub enum CallerScope {
/// A mesh/LoRa peer. Not exercised by this plan (mesh's existing
/// `!ai` path is Q&A-only, per `mesh/listener/assist.rs`'s own doc
/// comment) — the variant exists so the shape is right when a future
/// plan wires mesh callers into the shared loop.
Mesh { peer_id: String },
/// The authenticated operator using AIUI, identified by their neode-ui
/// session. This is the only variant this tracer's `assistant.chat`
/// RPC constructs.
LocalOperator { session_id: String },
}
impl CallerScope {
/// The sole source of tool authority `execute_tool` reads. No
/// `execute_tool` branch may read a caller-specific field directly
/// instead of going through this — that would reintroduce the
/// mesh-only assumption D-02 exists to retire.
pub fn granted_categories(&self) -> BTreeSet<PermissionCategory> {
match self {
// 13-05 replaces this hardcoded default with the persisted
// D-16 default-closed grants store — a data-source change, not
// an architectural one (per the plan's assumption-delta note).
CallerScope::LocalOperator { .. } => {
let mut set = BTreeSet::new();
set.insert(PermissionCategory::System);
set
}
// Intentionally conservative for this tracer: mesh has no
// tool-calling caller path wired up yet (today's mesh `!ai` is
// Q&A-only), so there is no real trusted_only/allowed_contacts
// grant to resolve. A future plan that wires the Mesh variant
// into the shared loop threads those existing per-caller
// controls through here — this is explicitly NOT the place a
// mesh-only field gets read directly by `execute_tool`.
CallerScope::Mesh { .. } => BTreeSet::new(),
}
}
}
/// Bundles what `execute_tool` needs regardless of which backend produced
/// the tool call: the curated registry, the caller's resolved authority,
/// and a handle back to the SAME `RpcHandler` every other authenticated
/// caller dispatches through — never an AI-only backdoor.
pub struct ToolExecCtx {
pub registry: tools::ToolRegistry,
pub caller: CallerScope,
pub handler: Arc<RpcHandler>,
}
/// Entry point: run one chat turn for `caller` through the shared loop.
/// Builds the visible-tool set from the caller's granted categories only
/// (D-16 — the model should never even see a tool it can't use), selects a
/// backend (Claude only, in this tracer), and runs it to a final answer.
pub async fn chat(handler: Arc<RpcHandler>, caller: CallerScope, user_text: String) -> Result<String> {
let registry = tools::registry();
let grants = caller.granted_categories();
let visible_tools = registry.visible_to(&grants);
let backend = backends::select_backend(handler.data_dir());
let system_prompt = "You are the Archipelago node's operator-control assistant. \
Only use the tools explicitly listed for this turn — never invent a tool name or call \
one that isn't listed. Every write requires human confirmation you cannot bypass or \
pre-approve on the user's behalf.";
let history = vec![tools::ChatMessage {
role: tools::Role::User,
text: Some(user_text),
tool_calls: vec![],
tool_results: vec![],
}];
let ctx = ToolExecCtx {
registry,
caller,
handler,
};
loop_::run_loop(backend.as_ref(), system_prompt, &visible_tools, history, &ctx).await
}