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:
co-authored by
Claude Opus 5
parent
15774d266f
commit
fe6ccff73c
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user