//! 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. The confirm-gate wait in `execute_tool` below is exactly such an //! await — it can suspend for minutes while a human decides — and it is //! reached holding no lock at all: the gate's own internal lock is scoped //! to map edits inside `confirm.rs`, and nothing here wraps the call in a //! guard. Keep it that way (AI-SPEC §4b.2). use anyhow::Result; use super::backends::{Backend, BackendTurn}; use super::tools::ToolDef; use super::tools::{ChatMessage, Role, ToolCall, ToolResult}; use super::ToolExecCtx; /// Hard stop — a looping model must never spin unbounded (D-05). pub const MAX_TURNS: usize = 8; /// Runs the multi-turn loop to a final answer. Returns `(answer, /// full_history)` — `full_history` is the caller-supplied `history` with /// every message this call appended (assistant tool-call turns, tool /// results, and a final trailing `Assistant` message carrying `answer` /// itself). 13-10/D-08 needs this to persist the SAME transcript /// `history.rs` records — `run_loop` returning only the answer string /// would leave the caller no way to see the tool-call/tool-result messages /// the loop built internally (Rule 3: structurally necessary for D-08's /// full-turn persistence, mirroring 13-05's precedent of touching a file /// outside its own plan's `files_modified` list when the plan's own intent /// requires it — see 13-10-SUMMARY.md's Deviations). pub async fn run_loop( backend: &dyn Backend, system: &str, tools: &[ToolDef], mut history: Vec, ctx: &ToolExecCtx, ) -> Result<(String, Vec)> { for _ in 0..MAX_TURNS { match backend.send(system, tools, &history).await? { BackendTurn::Text(answer) => { history.push(ChatMessage { role: Role::Assistant, text: Some(answer.clone()), tool_calls: vec![], tool_results: vec![], }); return Ok((answer, history)); } 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); } // AI-SPEC §4b.1 / D-05: a model that keeps emitting // malformed args for the same tool name must not be // allowed to spin for the full MAX_TURNS budget — abort // with an apology as soon as any tool name crosses 2 // consecutive validation failures, rather than continuing // to ask the model to try again. if ctx.should_abort() { let apology = "I'm stopping here — the same tool call kept failing \ validation. Could you rephrase what you'd like me to do?" .to_string(); history.push(ChatMessage { role: Role::Tool, text: None, tool_calls: vec![], tool_results: results, }); history.push(ChatMessage { role: Role::Assistant, text: Some(apology.clone()), tool_calls: vec![], tool_results: vec![], }); return Ok((apology, history)); } 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 — AI-SPEC §4b.1), and D-07 /// (every destructive tool suspends on the confirm gate before execution — /// only a matching human "yes" releases it; a decline or timeout returns a /// declined error result and executes nothing). /// /// `pub(crate)` (not private) so `assistant::tools`'s own test module can /// exercise this exact choke point directly for S-05/S-07 — the point of /// those tests is that the gate holds even when called the same way the /// real loop calls it, not a reimplementation of the gate in the test. pub(crate) 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), }; }; let granted = ctx.caller.granted_categories(ctx.handler.data_dir()).await; if !granted.contains(&tool.category) { return ToolResult { call_id: call.id.clone(), is_error: true, content: "not permitted — this category is not granted".to_string(), }; } let args = match tool.validate(&call.arguments) { Ok(args) => { ctx.reset_validation_failures(&call.name); args } Err(e) => { ctx.note_validation_failure(&call.name); return ToolResult { call_id: call.id.clone(), is_error: true, content: format!("invalid arguments: {e}"), }; } }; // Business-rule validation (an allowlisted settings key, an installed // app id) runs BEFORE the destructive/confirm gate below — otherwise a // plainly-wrong request (an unlisted key, `claude_api_key`, an unknown // app id) would be swallowed by the destructive branch's generic // "not yet implemented" placeholder instead of being refused with the // real reason (13-05 Task 1's `` criterion). This performs no // mutation itself — only a read-only id lookup for the app tools. if let Err(msg) = super::tools::validate_business_rules(&call.name, &args, ctx.handler.as_ref()).await { return ToolResult { call_id: call.id.clone(), is_error: true, content: msg, }; } if tool.destructive { // 13-08 UAT (T-13-50): an action the human already declined this // turn never re-prompts — a model retrying after "declined" would // otherwise re-open the dialog until the human gives in. Refused // here, before the gate, so no fresh confirmation is even minted. let action_key = super::confirm::action_key(&call.name, &args); if ctx.was_declined(&action_key) { return ToolResult { call_id: call.id.clone(), is_error: true, content: "the user already declined exactly this action in this turn — do NOT \ request it again. Tell the user it was declined and stop." .to_string(), }; } // D-07/D-11: the loop suspends here. The gate publishes a // NODE-AUTHORED description (never model text) that neode-ui's // trusted chrome fetches over the authenticated RPC session, and // approval binds to a node-minted nonce over the tool name and the // validated args — so what executes below is exactly what the // human read (S-02). This await is human-speed (up to // CONFIRM_TIMEOUT); no lock is held across it — see the module doc. match ctx.confirm.request(&call.id, tool, &args).await { super::confirm::Confirmed::Yes => {} super::confirm::Confirmed::No | super::confirm::Confirmed::TimedOut => { ctx.note_declined(action_key); return ToolResult { call_id: call.id.clone(), is_error: true, content: "the user declined this action — nothing was changed. Do not retry \ it and do not ask again; acknowledge the decline and stop." .to_string(), }; } } } // D-06: dispatch is a per-tool, hand-written decision recorded in // `tools::dispatch` — never a generic pass-through of the model's tool // name onto the RPC surface. match super::tools::dispatch(&call.name, &args, ctx.handler.as_ref()).await { Ok(v) => ToolResult { call_id: call.id.clone(), is_error: false, content: v.to_string(), }, Err(msg) => ToolResult { call_id: call.id.clone(), is_error: true, content: msg, }, } } #[cfg(test)] mod tests { use super::*; use crate::api::rpc::RpcHandler; use crate::assistant::backends::scripted::ScriptedBackend; use crate::assistant::tools::{registry, system_disk_status_tool}; use crate::assistant::{CallerScope, PermissionCategory}; 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, 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) -> ToolExecCtx { ToolExecCtx::new( registry(), CallerScope::LocalOperator { session_id: "test-session".to_string(), }, handler, ) } /// D-16 defaults to closed, so tests that exercise a real tool call /// must explicitly open the category first — this is the test-side /// analog of an operator toggling a category on in neode-ui. async fn grant(handler: &Arc, category: PermissionCategory) { let mut g = crate::assistant::grants::Grants::load(handler.data_dir()).await; g.set(category, true); g.save(handler.data_dir()).await.expect("save grants"); } #[tokio::test] async fn disk_status_tool_executes() { let (handler, _tmp) = test_rpc_handler().await; grant(&handler, PermissionCategory::System).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", None) .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); // Same handler, same shape — but the two calls sample live statvfs // figures at two different moments, and on a busy node (this test // box hosts a live one) free/used byte counters drift between the // samples. Compare the stable fields byte-for-byte instead of the // whole payload; identical `partition`/`total_bytes`/`encrypted` // still proves this is the SAME handler, not a parallel AI-only // code path. let direct_v: serde_json::Value = direct.clone(); let result_v: serde_json::Value = serde_json::from_str(&result.content).expect("tool result is the handler's JSON"); for field in ["partition", "total_bytes", "encrypted"] { assert_eq!( result_v.get(field), direct_v.get(field), "field {field} must come from the same handler" ); } 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, _history) = 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)" ); } }