Compare commits

...
Author SHA1 Message Date
archipelagoandClaude Opus 5 6ba52b2240 wip(13-01): checkpoint interrupted tracer work (assistant module + chat RPC)
Session died on a broken pipe with this work uncommitted in the executor
worktree. Committed verbatim, unverified — not a task completion. The
continuation executor may reset --soft this commit and recommit atomically
per task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:03:10 -04:00
archipelago 15774d266f docs(13): begin phase 13 execution on isolated lane 2026-08-03 11:29:25 -04:00
14 changed files with 989 additions and 12 deletions
+10 -10
View File
@@ -2,13 +2,13 @@
gsd_state_version: 1.0
milestone: v1.8.0
milestone_name: milestone
current_phase: 09
current_phase_name: BotFights Platform Upgrade
current_phase: 13
current_phase_name: aiui-functional-conversational-node-control-and-content-surf
status: executing
stopped_at: v1.7.120-alpha SHIPPED; 1.7.121 queue open — see .planning/RELEASE-1.7.121-TASKS.md (12 items, RESUME HERE section at the end)
last_updated: "2026-08-03T15:15:58.798Z"
last_activity: 2026-07-31
last_activity_desc: Phase 02 complete, transitioned to Phase 09
last_updated: "2026-08-03T15:28:57.184Z"
last_activity: 2026-08-03
last_activity_desc: Phase 13 execution started
progress:
total_phases: 13
completed_phases: 2
@@ -24,14 +24,14 @@ progress:
See: .planning/PROJECT.md (updated 2026-07-29)
**Core value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust.
**Current focus:** Phase 02 — ui-performance
**Current focus:** Phase 13aiui-functional-conversational-node-control-and-content-surf
## Current Position
Phase: 09 — BotFights Platform Upgrade
Plan: Not started
Status: Ready to execute
Last activity: 2026-07-31 — Phase 02 complete, transitioned to Phase 09
Phase: 13 (aiui-functional-conversational-node-control-and-content-surf) — EXECUTING
Plan: 1 of 15
Status: Executing Phase 13
Last activity: 2026-08-03 — Phase 13 execution started
Progress: [█████░░░░░] 54%
@@ -0,0 +1,81 @@
//! `assistant.*` RPC surface (D-01/D-02) — the front door onto the shared
//! assistant service in `crate::assistant`. Every later `assistant.*`
//! method (13-05's `list-tools`/`grants-*`, 13-08's `confirm-tool`, 13-10's
//! `history`) is added inside this file; `dispatcher.rs` registers exactly
//! one guarded arm for the whole `assistant.` prefix (see
//! `grep -c 'starts_with("assistant.")' dispatcher.rs` == 1), never a new
//! per-method literal arm.
use super::RpcHandler;
use anyhow::Result;
use std::sync::Arc;
impl RpcHandler {
/// Prefix sub-dispatcher for `assistant.*`. Reached only after the
/// caller has already passed the session-cookie + CSRF +
/// `role.can_access()` gate in `api/rpc/mod.rs:264-330` — no bespoke
/// auth here (asserted by
/// `assistant::loop_::tests::assistant_methods_require_session`, which
/// confirms `assistant.*` is absent from `UNAUTHENTICATED_METHODS`).
pub(in crate::api::rpc) async fn handle_assistant(
self: &Arc<Self>,
method: &str,
params: Option<serde_json::Value>,
session_token: &Option<String>,
) -> Result<serde_json::Value> {
match method {
"assistant.chat" => self.handle_assistant_chat(params, session_token).await,
other => anyhow::bail!("no such assistant method: {other}"),
}
}
/// assistant.chat — a single chat turn from the authenticated local
/// operator. Params: `{ "text": string }`. Returns `{ "text": string }`.
async fn handle_assistant_chat(
self: &Arc<Self>,
params: Option<serde_json::Value>,
session_token: &Option<String>,
) -> Result<serde_json::Value> {
let text = params
.as_ref()
.and_then(|p| p.get("text"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("text is required"))?;
// The caller's authenticated session identifies this LocalOperator —
// authority is resolved node-side from CallerScope, never from
// anything the browser or the model asserts about itself.
let session_id = session_token.clone().unwrap_or_default();
let caller = crate::assistant::CallerScope::LocalOperator { session_id };
let answer = crate::assistant::chat(Arc::clone(self), caller, text).await?;
Ok(serde_json::json!({ "text": answer }))
}
/// Internal-only bridge: executes a curated assistant tool against the
/// SAME `RpcHandler` method every authenticated RPC caller dispatches
/// through (never an AI-only backdoor). NOT itself an RPC method — only
/// `assistant::loop_::execute_tool` calls this, and only for tool names
/// present in the curated D-06 registry.
///
/// Rust module privacy is what requires this thin bridge:
/// `handle_system_disk_status` is `pub(in crate::api::rpc)`, so
/// `crate::assistant` (outside that module subtree) cannot call it
/// directly. This function lives inside `api::rpc` so it CAN call the
/// private handler, and re-exposes only the one curated method name a
/// tool call is allowed to reach — not the general RPC surface.
pub(crate) async fn assistant_dispatch_tool(&self, method: &str) -> Result<serde_json::Value> {
match method {
"system.disk-status" => self.handle_system_disk_status().await,
other => anyhow::bail!("assistant_dispatch_tool: no such handler for {other}"),
}
}
/// Read-only `data_dir` accessor for `crate::assistant`, which lives
/// outside `api::rpc`'s module tree and so cannot read the private
/// `config` field directly. Minimal, `pub(crate)`, no behavior change.
pub(crate) fn data_dir(&self) -> &std::path::Path {
&self.config.data_dir
}
}
@@ -444,6 +444,14 @@ impl RpcHandler {
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await,
"mesh.assistant-status" => self.handle_mesh_assistant_status().await,
"mesh.assistant-configure" => self.handle_mesh_assistant_configure(params).await,
// Phase 13 (D-01/D-02): the whole `assistant.*` surface lives in
// assistant_chat.rs, not as new arms here — this is the ONLY
// dispatcher.rs registration point for it. Every later
// assistant.* method (13-05, 13-08, 13-10) is added inside
// assistant_chat.rs's own match, never as a new arm in this file.
m if m.starts_with("assistant.") => {
self.handle_assistant(m, params, session_token).await
}
"mesh.schedule-message" => self.handle_mesh_schedule_message(params).await,
"mesh.list-scheduled" => self.handle_mesh_list_scheduled().await,
"mesh.cancel-scheduled" => self.handle_mesh_cancel_scheduled(params).await,
+7 -1
View File
@@ -2,7 +2,13 @@ use crate::session::SessionStore;
use std::net::IpAddr;
/// Methods that do not require a valid session cookie.
pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
///
/// `pub(crate)` (not just `pub(super)`) so `crate::assistant`'s test suite
/// can assert directly against the live list that the assistant RPC prefix
/// is never added to it (Phase-10 hard constraint) — see the re-export in
/// `api/rpc/mod.rs`. Read-visibility only; the list's contents and every
/// other visibility in this module are unchanged.
pub(crate) const UNAUTHENTICATED_METHODS: &[&str] = &[
"auth.login",
"auth.login.totp",
"auth.login.backup",
+8 -1
View File
@@ -1,5 +1,6 @@
mod analytics;
mod ark;
mod assistant_chat;
mod auth;
mod backup_rpc;
mod bitcoin;
@@ -59,9 +60,15 @@ use std::sync::Arc;
use tracing::{debug, error};
pub use middleware::PeerAddr;
// Re-exported `pub(crate)` (not just imported) so `crate::assistant`'s test
// suite can assert directly against the live list that `assistant.*` is
// never added to it — the Phase-10 hard constraint this crate must hold.
// The list's *contents* are unchanged; only its read-visibility widens from
// "this module" to "this crate".
pub(crate) use middleware::UNAUTHENTICATED_METHODS;
use middleware::{
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
CACHEABLE_METHODS,
};
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
@@ -0,0 +1,207 @@
//! The Claude leg of the D-04 backend chain — Anthropic Messages API with
//! `tools`/`tool_use`/`tool_result`. Modeled on
//! `mesh/listener/assist.rs::call_claude`'s HTTP client construction and
//! `api/rpc/mesh/assistant.rs`'s key-path convention, but NOT extended
//! in place: this is a new, tool-calling-capable request/response shape,
//! and its constants are new (AI-SPEC §3 Pitfall 6 — the mesh constants are
//! airtime-tuned for LoRa and must not be reused here).
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use super::{Backend, BackendTurn};
use crate::assistant::tools::{ChatMessage, Role, ToolCall, ToolDef};
const CLAUDE_URL: &str = "https://api.anthropic.com/v1/messages";
/// Kept in sync with `mesh/listener/assist.rs::CLAUDE_DEFAULT_MODEL` —
/// cheap and already proven fast enough; D-07 makes backend choice a
/// privacy/cost decision, not a capability-need one, so there is no reason
/// to default to a stronger model here.
const CLAUDE_MODEL: &str = "claude-haiku-4-5-20251001";
/// New, separate constant for the AIUI path's multi-turn tool loop (which
/// may include a network round trip) — NOT `assist.rs`'s `OLLAMA_TIMEOUT`
/// (60s, LoRa-airtime-tuned).
const ASSISTANT_HTTP_TIMEOUT: Duration = Duration::from_secs(180);
/// Raised from mesh's `512` — `tool_use` content blocks and multi-turn
/// reasoning need more headroom. Never left unbounded.
const ASSISTANT_MAX_TOKENS: u32 = 2048;
pub struct ClaudeBackend {
data_dir: PathBuf,
}
impl ClaudeBackend {
pub fn new(data_dir: PathBuf) -> Self {
Self { data_dir }
}
}
#[async_trait]
impl Backend for ClaudeBackend {
async fn send(
&self,
system: &str,
tools: &[ToolDef],
history: &[ChatMessage],
) -> Result<BackendTurn> {
// SAME key path `api/rpc/mesh/assistant.rs` probes — do not
// introduce a second key location (D-01, one key ledger).
let key = tokio::fs::read_to_string(self.data_dir.join("secrets/claude-api-key"))
.await
.map_err(|_| anyhow::anyhow!("Claude API key not configured on this node"))?;
let key = key.trim();
if key.is_empty() {
anyhow::bail!("Claude API key is empty");
}
let messages: Vec<Value> = history.iter().filter_map(message_to_wire).collect();
let claude_tools: Vec<Value> = tools
.iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"input_schema": t.parameters,
})
})
.collect();
let mut body = json!({
"model": CLAUDE_MODEL,
"max_tokens": ASSISTANT_MAX_TOKENS,
"system": system,
"messages": messages,
"stream": false,
});
if !claude_tools.is_empty() {
body["tools"] = json!(claude_tools);
// AI-SPEC §3 Pitfall 5: every tool_use.id from one assistant
// turn needs a matching tool_result before the next request.
// Disabling parallel tool use sidesteps that bookkeeping —
// D-06's tools are one deliberate action at a time anyway.
body["tool_choice"] = json!({"type": "auto", "disable_parallel_tool_use": true});
}
let client = reqwest::Client::builder()
.timeout(ASSISTANT_HTTP_TIMEOUT)
.build()?;
let resp = client
.post(CLAUDE_URL)
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let txt = resp.text().await.unwrap_or_default();
anyhow::bail!(
"Claude API HTTP {}: {}",
status,
txt.chars().take(180).collect::<String>()
);
}
let json: Value = resp.json().await?;
let blocks = json
.get("content")
.and_then(|c| c.as_array())
.cloned()
.unwrap_or_default();
let mut tool_calls = Vec::new();
let mut text = String::new();
for block in &blocks {
match block.get("type").and_then(|t| t.as_str()) {
Some("tool_use") => {
let id = block
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let name = block
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let arguments = block.get("input").cloned().unwrap_or_else(|| json!({}));
tool_calls.push(ToolCall {
id,
name,
arguments,
});
}
Some("text") => {
if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
text.push_str(t);
}
}
_ => {}
}
}
if !tool_calls.is_empty() {
Ok(BackendTurn::ToolCalls(tool_calls))
} else {
Ok(BackendTurn::Text(text))
}
}
}
/// Map one internal `ChatMessage` onto an Anthropic Messages API turn.
/// `Role::System` returns `None` — the system prompt is sent via the
/// top-level `system` field, not as a message in the array.
fn message_to_wire(msg: &ChatMessage) -> Option<Value> {
match msg.role {
Role::System => None,
Role::User => Some(json!({
"role": "user",
"content": msg.text.clone().unwrap_or_default(),
})),
Role::Assistant => {
if !msg.tool_calls.is_empty() {
let blocks: Vec<Value> = msg
.tool_calls
.iter()
.map(|c| {
json!({
"type": "tool_use",
"id": c.id,
"name": c.name,
"input": c.arguments,
})
})
.collect();
Some(json!({"role": "assistant", "content": blocks}))
} else {
Some(json!({
"role": "assistant",
"content": msg.text.clone().unwrap_or_default(),
}))
}
}
Role::Tool => {
let blocks: Vec<Value> = msg
.tool_results
.iter()
.map(|r| {
json!({
"type": "tool_result",
"tool_use_id": r.call_id,
"content": r.content,
"is_error": r.is_error,
})
})
.collect();
// Anthropic's tool_result blocks travel back as a "user" turn.
Some(json!({"role": "user", "content": blocks}))
}
}
}
@@ -0,0 +1,35 @@
//! The `Backend` trait — the wire-format-agnostic seam every model backend
//! (Ollama, Claude, Routstr) implements once. The loop and every tool are
//! written against this trait only; wire-format differences live entirely
//! inside each adapter.
use std::path::Path;
use anyhow::Result;
use async_trait::async_trait;
use super::tools::{ChatMessage, ToolCall, ToolDef};
pub mod claude;
#[cfg(test)]
pub mod scripted;
pub enum BackendTurn {
Text(String),
ToolCalls(Vec<ToolCall>),
}
#[async_trait]
pub trait Backend: Send + Sync {
async fn send(&self, system: &str, tools: &[ToolDef], history: &[ChatMessage]) -> Result<BackendTurn>;
}
/// D-04's backend chain: local Ollama first (node data never leaves the
/// node when a local model is available), then Claude, then Routstr. Only
/// the Claude leg is implemented in this tracer — `backends/ollama.rs`
/// (13-10) and `backends/routstr.rs` (13-13) slot in ahead of and behind it
/// without changing the `Backend` trait; that is the architectural
/// commitment this tracer proves.
pub fn select_backend(data_dir: &Path) -> Box<dyn Backend> {
Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf()))
}
@@ -0,0 +1,44 @@
//! Test-only backend that replays a canned sequence of turns. Never
//! compiles into the shipped binary — gated by `#![cfg(test)]` here AND by
//! `#[cfg(test)] pub mod scripted;` in `backends/mod.rs`.
#![cfg(test)]
use std::sync::Mutex;
use anyhow::Result;
use async_trait::async_trait;
use super::{Backend, BackendTurn};
use crate::assistant::tools::{ChatMessage, ToolDef};
pub struct ScriptedBackend {
turns: Mutex<Vec<BackendTurn>>,
}
impl ScriptedBackend {
/// `turns` are consumed in the order given — the first call to `send()`
/// returns `turns[0]`, the second `turns[1]`, and so on.
pub fn new(turns: Vec<BackendTurn>) -> Self {
let mut turns = turns;
turns.reverse();
Self {
turns: Mutex::new(turns),
}
}
}
#[async_trait]
impl Backend for ScriptedBackend {
async fn send(
&self,
_system: &str,
_tools: &[ToolDef],
_history: &[ChatMessage],
) -> Result<BackendTurn> {
let mut turns = self.turns.lock().expect("ScriptedBackend mutex poisoned");
turns
.pop()
.ok_or_else(|| anyhow::anyhow!("ScriptedBackend exhausted — no more turns queued"))
}
}
+237
View File
@@ -0,0 +1,237 @@
//! The multi-turn tool-calling loop (D-01/D-02). No analog exists elsewhere
//! in this codebase — this is the first tool-calling agent loop ever
//! written here (confirmed by 13-RESEARCH.md/13-AI-SPEC.md); built directly
//! from `13-AI-SPEC.md` §3/§4's sketch.
//!
//! Concurrency discipline inherited from `mesh/listener/assist.rs`'s own
//! doc comment ("Spawned off the radio loop so it never blocks"): never
//! hold a shared lock across a `.await` that can block for human-response
//! time. `execute_tool` below holds no lock at all in this tracer — there
//! is nothing yet to hold one across (13-08's confirm gate is what
//! introduces that discipline requirement for real).
use anyhow::Result;
use super::backends::{Backend, BackendTurn};
use super::tools::{ChatMessage, Role, ToolCall, ToolResult};
use super::tools::ToolDef;
use super::ToolExecCtx;
/// Hard stop — a looping model must never spin unbounded (D-05).
pub const MAX_TURNS: usize = 8;
pub async fn run_loop(
backend: &dyn Backend,
system: &str,
tools: &[ToolDef],
mut history: Vec<ChatMessage>,
ctx: &ToolExecCtx,
) -> Result<String> {
for _ in 0..MAX_TURNS {
match backend.send(system, tools, &history).await? {
BackendTurn::Text(answer) => return Ok(answer),
BackendTurn::ToolCalls(calls) => {
history.push(ChatMessage {
role: Role::Assistant,
text: None,
tool_calls: calls.clone(),
tool_results: vec![],
});
let mut results = Vec::with_capacity(calls.len());
for call in &calls {
results.push(execute_tool(call, ctx).await);
}
history.push(ChatMessage {
role: Role::Tool,
text: None,
tool_calls: vec![],
tool_results: results,
});
}
}
}
anyhow::bail!("assistant loop exceeded MAX_TURNS without a final answer — stopping, not looping forever")
}
/// The single choke point every tool call passes through, regardless of
/// which backend produced it. Enforces, in order: D-06 (curated allowlist —
/// unknown names are refused, never silently ignored), D-16 (default-closed
/// category grants — re-checked here even though the system prompt already
/// omits ungranted tools; never trust that as the only enforcement layer),
/// schema validation (never coerce, never guess), and D-07 (every
/// destructive tool suspends for confirmation — 13-08 fills that branch in;
/// there are no destructive tools registered yet, so it is unreachable
/// today).
async fn execute_tool(call: &ToolCall, ctx: &ToolExecCtx) -> ToolResult {
let Some(tool) = ctx.registry.get(&call.name) else {
return ToolResult {
call_id: call.id.clone(),
is_error: true,
content: format!("no such tool: {}", call.name),
};
};
if !ctx.caller.granted_categories().contains(&tool.category) {
return ToolResult {
call_id: call.id.clone(),
is_error: true,
content: "not permitted — this category is not granted".to_string(),
};
}
if let Err(e) = tool.validate(&call.arguments) {
return ToolResult {
call_id: call.id.clone(),
is_error: true,
content: format!("invalid arguments: {e}"),
};
}
if tool.destructive {
return ToolResult {
call_id: call.id.clone(),
is_error: true,
content: "destructive tool execution is not yet implemented".to_string(),
};
}
match call.name.as_str() {
// Dispatches to the SAME RpcHandler method every other authenticated
// caller uses (no AI-only backdoor) — see `assistant_dispatch_tool`
// in `api/rpc/assistant_chat.rs` for why this bridge exists.
"system_disk_status" => match ctx
.handler
.assistant_dispatch_tool("system.disk-status")
.await
{
Ok(v) => ToolResult {
call_id: call.id.clone(),
is_error: false,
content: v.to_string(),
},
Err(e) => ToolResult {
call_id: call.id.clone(),
is_error: true,
content: format!("tool execution failed: {e}"),
},
},
other => ToolResult {
call_id: call.id.clone(),
is_error: true,
content: format!("no execution wired for tool: {other}"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assistant::backends::scripted::ScriptedBackend;
use crate::assistant::tools::{registry, system_disk_status_tool};
use crate::assistant::{CallerScope, PermissionCategory};
use crate::api::rpc::RpcHandler;
use serde_json::json;
use std::sync::Arc;
/// A minimal but real `RpcHandler` for tests: a fresh temp `data_dir`
/// (no `/var/lib/archipelago` writes), no orchestrator (container RPCs
/// aren't exercised here), matching the doc comment on `orchestrator`
/// that this is exactly why the field is `Option`.
async fn test_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
let tmp = tempfile::tempdir().expect("tempdir");
let mut config = crate::config::Config::default();
config.data_dir = tmp.path().to_path_buf();
let state_manager = Arc::new(crate::state::StateManager::new());
let metrics_store = Arc::new(crate::monitoring::MetricsStore::new());
let session_store =
crate::session::SessionStore::new_for_tests(tmp.path().join("sessions.json"));
let handler = RpcHandler::new(
config,
state_manager,
metrics_store,
session_store,
None,
None,
)
.await
.expect("RpcHandler::new");
(Arc::new(handler), tmp)
}
fn local_operator_ctx(handler: Arc<RpcHandler>) -> ToolExecCtx {
ToolExecCtx {
registry: registry(),
caller: CallerScope::LocalOperator {
session_id: "test-session".to_string(),
},
handler,
}
}
#[tokio::test]
async fn disk_status_tool_executes() {
let (handler, _tmp) = test_rpc_handler().await;
// The real figures the tool path returns must match what the SAME
// handler returns when dispatched directly — proving `execute_tool`
// is not a parallel, AI-only code path.
let direct = handler
.assistant_dispatch_tool("system.disk-status")
.await
.expect("direct dispatch");
let ctx = local_operator_ctx(handler.clone());
let call = ToolCall {
id: "call-1".to_string(),
name: "system_disk_status".to_string(),
arguments: json!({}),
};
let result = execute_tool(&call, &ctx).await;
assert!(!result.is_error, "tool call errored: {}", result.content);
assert_eq!(result.content, direct.to_string());
assert!(result.content.contains("total_bytes"));
// Exercise the whole loop: a ScriptedBackend that names the tool,
// then answers — proving the real figures reached the final answer
// path (the answer itself is the second scripted turn, matching
// AI-SPEC's run_loop shape; the tool result that fed into it is
// asserted above).
let backend = ScriptedBackend::new(vec![
BackendTurn::ToolCalls(vec![call.clone()]),
BackendTurn::Text("Disk space report generated.".to_string()),
]);
let tools_list = vec![system_disk_status_tool()];
let answer = run_loop(&backend, "system prompt", &tools_list, vec![], &ctx)
.await
.expect("run_loop");
assert_eq!(answer, "Disk space report generated.");
}
#[tokio::test]
async fn unknown_tool_is_refused_not_ignored() {
let (handler, _tmp) = test_rpc_handler().await;
let ctx = local_operator_ctx(handler);
let call = ToolCall {
id: "call-1".to_string(),
name: "delete_everything".to_string(),
arguments: json!({}),
};
let result = execute_tool(&call, &ctx).await;
assert!(result.is_error);
assert!(result.content.contains("no such tool"), "{}", result.content);
}
/// Phase-10 hard constraint: `assistant.*` must never be reachable
/// unauthenticated. Asserted directly against the live list, not
/// assumed.
#[test]
fn assistant_methods_require_session() {
let has_assistant_method = crate::api::rpc::UNAUTHENTICATED_METHODS
.iter()
.any(|m| m.starts_with("assistant."));
assert!(
!has_assistant_method,
"assistant.* must never be added to UNAUTHENTICATED_METHODS (Phase-10 hard constraint)"
);
}
}
+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
}
+170
View File
@@ -0,0 +1,170 @@
//! D-06: a curated, hand-written tool registry. Never derived from
//! `api::rpc::dispatcher`'s method table — every capability the chat has is
//! a deliberate decision recorded here, and the model never sees the full
//! RPC surface. No `schemars` — that crate is absent from `Cargo.toml` and
//! from 13-RESEARCH.md's Package Legitimacy Audit, so `parameters` below is
//! a hand-written JSON Schema object literal instead.
use std::collections::{BTreeSet, HashMap};
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::{json, Value};
use super::PermissionCategory;
/// The backend-agnostic in/out of a tool invocation — the same shape
/// regardless of which adapter (Ollama/Claude/Routstr) produced it.
#[derive(Debug, Clone)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
#[derive(Debug, Clone)]
pub struct ToolResult {
pub call_id: String,
pub content: String,
pub is_error: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub role: Role,
/// Plain text, or (a future plan's) D-10-wrapped untrusted content.
pub text: Option<String>,
/// Assistant-authored tool calls made THIS turn (role: Assistant).
pub tool_calls: Vec<ToolCall>,
/// Tool results fed back THIS turn (role: Tool).
pub tool_results: Vec<ToolResult>,
}
/// D-06: one curated, hand-written tool. Never generated from the RPC
/// dispatcher — the curated set IS the D-09 authority boundary.
#[derive(Clone)]
pub struct ToolDef {
pub name: &'static str,
pub description: &'static str,
/// JSON Schema `{"type":"object","properties":{...},"required":[...]}`,
/// hand-written and pinned adjacent to the args struct it must never
/// drift from — see `disk_status_schema_round_trips_required_keys`.
pub parameters: Value,
pub category: PermissionCategory,
/// D-07: true => confirm gate, no exceptions. There are no destructive
/// tools in this tracer's registry; `execute_tool` refuses this branch
/// with a not-yet-implemented error until 13-08 fills it in.
pub destructive: bool,
}
/// Args for `system_disk_status` — takes no parameters.
#[derive(Debug, Deserialize)]
pub struct SystemDiskStatusArgs {}
impl ToolDef {
/// Deserialize + validate model-produced arguments before ANY
/// execution. Never coerce, never guess, never panic on a mismatch —
/// refuse and let the caller turn the error into a tool result the
/// model can recover from.
///
/// This tracer's registry has exactly one tool, so this is a direct
/// deserialize; a future plan adding a second tool dispatches by
/// `self.name` here before deserializing into that tool's own args type.
pub fn validate(&self, raw: &Value) -> Result<SystemDiskStatusArgs> {
serde_json::from_value(raw.clone())
.context("tool arguments did not match the declared schema")
}
}
/// `system_disk_status` — category `System`, read-only. Reports free and
/// total disk space on this node via the same `system.disk-status` handler
/// every other authenticated caller uses.
pub fn system_disk_status_tool() -> ToolDef {
ToolDef {
name: "system_disk_status",
description: "Report free and total disk space on this Archipelago node.",
parameters: json!({
"type": "object",
"properties": {},
"required": [],
}),
category: PermissionCategory::System,
destructive: false,
}
}
/// D-06's curated allowlist, name-indexed.
pub struct ToolRegistry {
tools: HashMap<&'static str, ToolDef>,
}
impl ToolRegistry {
pub fn get(&self, name: &str) -> Option<&ToolDef> {
self.tools.get(name)
}
/// The subset of the registry visible to a caller with `grants`. D-16:
/// an unconfigured node's system prompt should advertise close to zero
/// tools — the model should never even see a tool it can't use.
pub fn visible_to(&self, grants: &BTreeSet<PermissionCategory>) -> Vec<ToolDef> {
self.tools
.values()
.filter(|t| grants.contains(&t.category))
.cloned()
.collect()
}
}
/// The curated D-06 registry. This tracer registers exactly one tool.
pub fn registry() -> ToolRegistry {
let mut tools = HashMap::new();
let tool = system_disk_status_tool();
tools.insert(tool.name, tool);
ToolRegistry { tools }
}
#[cfg(test)]
mod tests {
use super::*;
/// The schema sent to the model and the struct used to deserialize its
/// output must never silently drift apart. Round-trip the schema's
/// declared `required` keys through the args struct.
#[test]
fn disk_status_schema_round_trips_required_keys() {
let tool = system_disk_status_tool();
let required = tool
.parameters
.get("required")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
let mut obj = serde_json::Map::new();
for key in &required {
if let Some(k) = key.as_str() {
obj.insert(k.to_string(), Value::Null);
}
}
let value = Value::Object(obj);
let parsed: Result<SystemDiskStatusArgs, _> = serde_json::from_value(value);
assert!(parsed.is_ok(), "schema/args struct drift: {:?}", parsed.err());
}
#[test]
fn registry_visible_to_respects_grants() {
let reg = registry();
let mut grants = BTreeSet::new();
assert!(reg.visible_to(&grants).is_empty());
grants.insert(PermissionCategory::System);
assert_eq!(reg.visible_to(&grants).len(), 1);
}
}
+1
View File
@@ -27,6 +27,7 @@ use tracing::info;
mod api;
mod app_ops;
mod assistant;
mod auth;
mod avatar;
mod backup;
+32
View File
@@ -5,6 +5,7 @@ import type {
AIContextCategory,
ArchyContextResponse,
ArchyActionResponse,
ArchyChatResponse,
} from '@/types/aiui-protocol'
import { useAIPermissionsStore } from '@/stores/aiPermissions'
import { useAppStore } from '@/stores/app'
@@ -81,6 +82,37 @@ export class ContextBroker {
case 'theme:request':
this.sendTheme()
break
case 'chat:request':
this.handleChatRequest(msg.id, msg.text)
break
}
}
// Note: no permission category is threaded through here on purpose.
// Authority for a chat turn is resolved node-side from the RPC session's
// CallerScope (assistant.chat, core/archipelago/src/assistant/mod.rs) —
// duplicating a browser-side gate here would recreate the second,
// divergent security model D-02 exists to prevent. Do not "helpfully"
// add a permission check back into this handler.
private async handleChatRequest(id: string, text: string) {
try {
const result = await rpcClient.call<{ text: string }>({
method: 'assistant.chat',
params: { text },
})
this.postToIframe({
type: 'chat:response',
id,
success: true,
text: result.text,
} satisfies ArchyChatResponse)
} catch (err) {
this.postToIframe({
type: 'chat:response',
id,
success: false,
error: err instanceof Error ? err.message : 'Chat request failed',
} satisfies ArchyChatResponse)
}
}
+24
View File
@@ -45,11 +45,24 @@ export interface AIUIThemeRequest {
type: 'theme:request'
}
/**
* A chat turn from AIUI's embedded-mode client. Carries only the raw user
* text — tool selection is node-side (D-01/D-03) and must never be
* expressible as an AIUI-originated action, so this is deliberately NOT an
* `AIActionType` member.
*/
export interface AIUIChatRequest {
type: 'chat:request'
id: string
text: string
}
export type AIUIRequest =
| AIUIContextRequest
| AIUIActionRequest
| AIUIReadyMessage
| AIUIThemeRequest
| AIUIChatRequest
// ─── Archy → AIUI (Responses) ──────────────────────────────────────────────
@@ -81,11 +94,22 @@ export interface ArchyPermissionsUpdate {
categories: AIContextCategory[]
}
/** The node's answer to a `chat:request`. On RPC failure, `error` carries
* only the error message — never the raw exception object. */
export interface ArchyChatResponse {
type: 'chat:response'
id: string
success: boolean
text?: string
error?: string
}
export type ArchyResponse =
| ArchyContextResponse
| ArchyActionResponse
| ArchyThemeResponse
| ArchyPermissionsUpdate
| ArchyChatResponse
// ─── All messages ───────────────────────────────────────────────────────────