diff --git a/core/archipelago/src/api/rpc/mesh/assistant.rs b/core/archipelago/src/api/rpc/mesh/assistant.rs index 5a9a102b..9c161c32 100644 --- a/core/archipelago/src/api/rpc/mesh/assistant.rs +++ b/core/archipelago/src/api/rpc/mesh/assistant.rs @@ -161,7 +161,10 @@ impl RpcHandler { } /// Probe the local Ollama HTTP API; return (detected, model_names). -async fn detect_ollama() -> (bool, Vec) { +/// +/// `pub(crate)`: reused directly by `crate::assistant::backends::select_backend` +/// (13-10, D-04) rather than a second probe being written there. +pub(crate) async fn detect_ollama() -> (bool, Vec) { let client = match reqwest::Client::builder() .timeout(Duration::from_secs(2)) .build() diff --git a/core/archipelago/src/api/rpc/mesh/mod.rs b/core/archipelago/src/api/rpc/mesh/mod.rs index 51b53bc7..82191d06 100644 --- a/core/archipelago/src/api/rpc/mesh/mod.rs +++ b/core/archipelago/src/api/rpc/mesh/mod.rs @@ -1,4 +1,7 @@ -mod assistant; +// pub(crate): `detect_ollama()` is reused by +// `crate::assistant::backends::select_backend` (13-10, D-04) — the +// existing probe, not a second one. +pub(crate) mod assistant; mod bitcoin_ops; mod flash; mod messaging; diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 276e8fe0..c458036f 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -19,7 +19,11 @@ mod identity; mod interfaces; pub(crate) mod lnd; mod marketplace; -mod mesh; +// pub(crate): 13-10's `assistant::backends::select_backend` reuses +// `mesh::assistant::detect_ollama()` (D-04) rather than re-probing — +// matches the existing `pub(crate) mod bitcoin_relay;`/`pub(crate) mod +// lnd;` convention already in this file for cross-module reuse. +pub(crate) mod mesh; mod middleware; mod monitoring; mod music; @@ -69,8 +73,7 @@ pub use middleware::PeerAddr; // "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, + derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS, }; use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse}; diff --git a/core/archipelago/src/assistant/backends/mod.rs b/core/archipelago/src/assistant/backends/mod.rs index 6c71a2a2..6c5b2c58 100644 --- a/core/archipelago/src/assistant/backends/mod.rs +++ b/core/archipelago/src/assistant/backends/mod.rs @@ -11,6 +11,7 @@ use async_trait::async_trait; use super::tools::{ChatMessage, ToolCall, ToolDef}; pub mod claude; +pub mod ollama; #[cfg(test)] pub mod scripted; @@ -29,12 +30,198 @@ pub trait Backend: Send + Sync { ) -> Result; } +/// D-04's identified backends — used for tracing which backend answered a +/// given turn. Grows a `Routstr` variant once 13-13 lands it. Deliberately +/// NOT carried into `ChatMessage`/`history.rs` (outside this plan's file +/// scope; per-turn backend attribution in the persisted transcript is a +/// natural follow-up, not required by any of 13-10's behaviors). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendId { + Ollama, + Claude, +} + +impl std::fmt::Display for BackendId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BackendId::Ollama => write!(f, "ollama"), + BackendId::Claude => write!(f, "claude"), + } + } +} + +/// D-04's per-call fallback: try `primary`'s `send()`, and on a transport +/// error fall through to `secondary` for that SAME call rather than +/// failing the whole turn — a local model that answers earlier turns and +/// then drops off mid-loop (Ollama restarted, OOM-killed, network blip) +/// still completes the turn via Claude instead of surfacing an error to +/// the user. Generic over both legs so tests can exercise the fallthrough +/// with lightweight stub backends instead of a live network leg. +struct FallbackChain { + primary: Box, + primary_id: BackendId, + secondary: Box, +} + +#[async_trait] +impl Backend for FallbackChain { + async fn send( + &self, + system: &str, + tools: &[ToolDef], + history: &[ChatMessage], + ) -> Result { + match self.primary.send(system, tools, history).await { + Ok(turn) => Ok(turn), + Err(e) => { + tracing::warn!( + backend = %self.primary_id, + error = %e, + "D-04: backend transport error mid-turn — falling through to the next backend rather than failing this turn" + ); + self.secondary.send(system, tools, history).await + } + } + } +} + +/// Pure decision logic for D-04's leading leg — whether Ollama should be +/// selected given the two facts `select_backend` gathers from live probes +/// (`detect_ollama()`/`ollama::model_supports_tools()`). Extracted so the +/// decision table itself is directly testable without faking a network +/// seam for both probes. +fn ollama_is_selectable(detected: bool, tool_capable: bool) -> bool { + detected && tool_capable +} + /// 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 { - Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())) +/// node when a local model is available and tool-capable), then Claude. +/// The Routstr slot (13-13) inserts as a third leg without changing the +/// `Backend` trait or this function's shape — the architectural commitment +/// the 13-01 tracer proved and this plan fills the first real leg of. +/// +/// Reuses `detect_ollama()` — the SAME probe `mesh.assistant-status` +/// reports — rather than writing a second one. Ollama being unreachable OR +/// its configured model being reachable-but-not-tool-capable both fall +/// through to Claude, each with a logged reason — never a silent, +/// tools-free degrade. +pub async fn select_backend(data_dir: &Path) -> (Box, BackendId) { + let (detected, _models) = crate::api::rpc::mesh::assistant::detect_ollama().await; + let model = ollama::OLLAMA_DEFAULT_MODEL; + let tool_capable = if detected { + ollama::model_supports_tools(ollama::OLLAMA_BASE_URL, model).await + } else { + false + }; + + if ollama_is_selectable(detected, tool_capable) { + tracing::info!( + model, + "D-04: local Ollama selected — node data stays on-node this turn" + ); + let primary = + ollama::OllamaBackend::new(ollama::OLLAMA_BASE_URL.to_string(), model.to_string()); + let secondary = claude::ClaudeBackend::new(data_dir.to_path_buf()); + let chain = FallbackChain { + primary: Box::new(primary), + primary_id: BackendId::Ollama, + secondary: Box::new(secondary), + }; + return (Box::new(chain), BackendId::Ollama); + } + + if !detected { + tracing::info!("D-04: Ollama not detected — falling through to Claude"); + } else { + tracing::info!( + model, + "D-04: Ollama detected but the configured model is not tool-capable — falling through to Claude" + ); + } + ( + Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())), + BackendId::Claude, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assistant::tools::{ChatMessage, ToolDef}; + + struct ErroringBackend; + #[async_trait] + impl Backend for ErroringBackend { + async fn send( + &self, + _system: &str, + _tools: &[ToolDef], + _history: &[ChatMessage], + ) -> Result { + anyhow::bail!("stub transport error") + } + } + + struct OkBackend(&'static str); + #[async_trait] + impl Backend for OkBackend { + async fn send( + &self, + _system: &str, + _tools: &[ToolDef], + _history: &[ChatMessage], + ) -> Result { + Ok(BackendTurn::Text(self.0.to_string())) + } + } + + /// Behavior: an Ollama transport error falls through to the next + /// backend rather than failing the turn. + #[tokio::test] + async fn ollama_transport_error_falls_through_to_next_backend() { + let chain = FallbackChain { + primary: Box::new(ErroringBackend), + primary_id: BackendId::Ollama, + secondary: Box::new(OkBackend("answered by claude")), + }; + let result = chain + .send("sys", &[], &[]) + .await + .expect("falls through, does not fail the turn"); + assert!(matches!(result, BackendTurn::Text(t) if t == "answered by claude")); + } + + #[tokio::test] + async fn healthy_primary_never_reaches_secondary() { + let chain = FallbackChain { + primary: Box::new(OkBackend("answered by ollama")), + primary_id: BackendId::Ollama, + secondary: Box::new(ErroringBackend), + }; + let result = chain.send("sys", &[], &[]).await.expect("primary answers"); + assert!(matches!(result, BackendTurn::Text(t) if t == "answered by ollama")); + } + + /// Behavior: `select_backend` returns Ollama when reachable and + /// tool-capable; falls through to Claude when unreachable, and also + /// when reachable but not tool-capable. + #[test] + fn ollama_is_selectable_truth_table() { + assert!( + ollama_is_selectable(true, true), + "reachable + tool-capable => selectable" + ); + assert!( + !ollama_is_selectable(false, true), + "unreachable => falls through to Claude" + ); + assert!( + !ollama_is_selectable(true, false), + "reachable but not tool-capable => falls through to Claude" + ); + assert!( + !ollama_is_selectable(false, false), + "neither => falls through to Claude" + ); + } } diff --git a/core/archipelago/src/assistant/backends/ollama.rs b/core/archipelago/src/assistant/backends/ollama.rs new file mode 100644 index 00000000..6ab57b2f --- /dev/null +++ b/core/archipelago/src/assistant/backends/ollama.rs @@ -0,0 +1,600 @@ +//! The Ollama leg of the D-04 backend chain — `POST /api/chat` with a +//! `messages` array and a `tools` array, and a `message.tool_calls` +//! response. This is a DIFFERENT endpoint and a DIFFERENT request/response +//! shape from `mesh/listener/assist.rs::call_ollama`'s single-shot prompt +//! endpoint, which has no tool-calling support at all (13-AI-SPEC.md §3 +//! Pitfall 1) — `call_ollama` is not extended in place. +//! +//! Two cross-provider gotchas live entirely at this adapter's edge, never +//! in the shared loop (`loop_.rs`) or the shared tool model (`tools.rs`): +//! Ollama's `function.arguments` arrives as an already-parsed JSON object, +//! never a JSON-encoded string that would need a second parse pass the way +//! OpenAI-shape providers' arguments do (Pitfall 2), and Ollama's tool +//! calls carry no `id` field at all, so this file synthesizes one +//! (Pitfall 3) or the loop's result-matching would break silently. + +use std::collections::HashMap; +use std::sync::{Mutex as StdMutex, OnceLock}; +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}; + +/// The real local Ollama server. `OllamaBackend::new` takes a `base_url` +/// explicitly rather than hardcoding this everywhere, so this module's own +/// tests can point the SAME type at a local HTTP stub instead — see the +/// `tests` module below. +pub const OLLAMA_BASE_URL: &str = "http://localhost:11434"; +/// Ollama's tool-calling chat endpoint path. Tool-calling needs THIS +/// endpoint's `messages`/`tools` request shape and `message.tool_calls` +/// response shape — the older single-shot prompt endpoint +/// `assist.rs::call_ollama` posts to has no tool-calling support at all. +pub const OLLAMA_CHAT_URL: &str = "/api/chat"; +/// Ollama's model-info endpoint — queried by `model_supports_tools` to +/// turn 13-AI-SPEC.md §4's `[ASSUMED]` note about `qwen2.5-coder` into a +/// runtime fact instead of a guess. +const OLLAMA_SHOW_URL: &str = "/api/show"; +/// Default model when the node hasn't configured one — mirrors +/// `assist.rs::DEFAULT_MODEL`. Never trusted blindly: `model_supports_tools` +/// checks THIS specific model's capability before it is ever handed a +/// tool; a non-tool-capable result falls through to Claude (see +/// `backends::select_backend`) rather than silently degrading to a +/// tools-free assistant. +pub const OLLAMA_DEFAULT_MODEL: &str = "qwen2.5-coder"; +/// Explicit generation-length cap (`options.num_predict`), sent on every +/// request — never left unbounded (AI-SPEC §4b.3). Kept below the Claude +/// leg's 2048-token cap: local models generate slower per token than +/// Claude, so a smaller cap keeps a local turn's worst-case latency sane +/// on a modest node. +const OLLAMA_NUM_PREDICT: u32 = 1024; +/// New, separate HTTP timeout for the AIUI Ollama path's multi-turn tool +/// loop — deliberately NOT the mesh module's own 60-second, LoRa-airtime- +/// tuned constant of the same shape (AI-SPEC §3 Pitfall 6). Reusing that +/// one here would under-time-out a legitimate multi-turn local-model round +/// trip that has no radio-airtime constraint at all. +const OLLAMA_HTTP_TIMEOUT: Duration = Duration::from_secs(120); + +/// The Ollama leg of the D-04 backend chain. `base_url` is explicit (never +/// a hardcoded constant read directly inside `send()`) so production +/// (`OLLAMA_BASE_URL`) and this module's own tests (a local HTTP stub) +/// construct the identical type against different servers. +pub struct OllamaBackend { + base_url: String, + model: String, +} + +impl OllamaBackend { + pub fn new(base_url: String, model: String) -> Self { + Self { base_url, model } + } +} + +#[async_trait] +impl Backend for OllamaBackend { + async fn send( + &self, + system: &str, + tools: &[ToolDef], + history: &[ChatMessage], + ) -> Result { + let mut messages: Vec = vec![json!({ "role": "system", "content": system })]; + messages.extend(history.iter().flat_map(message_to_wire)); + + let ollama_tools: Vec = tools + .iter() + .map(|t| { + json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters, + }, + }) + }) + .collect(); + + let mut body = json!({ + "model": self.model, + "messages": messages, + // Requested non-streaming for every turn while the loop is + // still deciding whether a tool is being called — partial + // JSON tool arguments cannot be structurally validated + // mid-stream (AI-SPEC §4b.2). The final, tool-free answer turn + // would be a legitimate streaming candidate, but this adapter + // always buffers: the shared loop needs the complete text back + // either way. + "stream": false, + // Generation cap, always explicit — never unbounded. + "options": { "num_predict": OLLAMA_NUM_PREDICT }, + }); + if !ollama_tools.is_empty() { + body["tools"] = json!(ollama_tools); + } + + let client = reqwest::Client::builder() + .timeout(OLLAMA_HTTP_TIMEOUT) + .build()?; + let url = format!("{}{}", self.base_url, OLLAMA_CHAT_URL); + let resp = client.post(&url).json(&body).send().await?; + + if !resp.status().is_success() { + let status = resp.status(); + let txt = resp.text().await.unwrap_or_default(); + anyhow::bail!( + "Ollama chat HTTP {}: {}", + status, + txt.chars().take(180).collect::() + ); + } + + let json: Value = resp.json().await?; + let message = json.get("message").cloned().unwrap_or_default(); + let raw_calls = message + .get("tool_calls") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + if raw_calls.is_empty() { + let text = message + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + return Ok(BackendTurn::Text(text)); + } + + let tool_calls: Vec = raw_calls + .iter() + .enumerate() + .map(|(idx, raw)| { + let name = raw + .get("function") + .and_then(|f| f.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + // Ollama's function.arguments arrives as an already-parsed + // JSON object. Assigning it straight through is the + // CORRECT normalization here — re-parsing it as a string + // (right for OpenAI-shape providers, per AI-SPEC §3 + // Pitfall 2) would be a type error against this wire + // shape, and is never done anywhere in this file. + let arguments = raw + .get("function") + .and_then(|f| f.get("arguments")) + .cloned() + .unwrap_or_else(|| json!({})); + ToolCall { + // Ollama gives tool calls no id at all — synthesize a + // stable, non-empty, unique-within-this-turn id. + // Leaving this empty would make the loop's + // result-matching break silently, which is worse than + // failing loudly. + id: synthesize_call_id(idx), + name, + arguments, + } + }) + .collect(); + Ok(BackendTurn::ToolCalls(tool_calls)) + } +} + +/// A stable, non-empty, unique-within-the-turn id for a tool call whose +/// wire response carried none. `idx` is the call's position within THIS +/// turn's `tool_calls` array — sufficient for uniqueness because a fresh +/// set of ids is synthesized for every `send()` call (every model turn), +/// never reused or carried across turns. +fn synthesize_call_id(idx: usize) -> String { + format!("ollama-tool-{idx}") +} + +/// Map one internal `ChatMessage` onto zero or more Ollama chat wire +/// messages. `Role::Tool` can carry more than one `ToolResult` in a single +/// `ChatMessage` (a batch of tool calls from one turn) — Ollama's wire +/// format has no analog to Claude's single "user" turn wrapping an array +/// of `tool_result` blocks, so each result becomes its own `role: "tool"` +/// message instead. +fn message_to_wire(msg: &ChatMessage) -> Vec { + match msg.role { + // The system prompt is sent as the FIRST message in `send()` + // itself, sourced from the `system` parameter, never from + // history — this arm exists for completeness (mirrors + // `claude.rs`'s handling) but no `ChatMessage` of role System is + // constructed anywhere in this codebase today. + Role::System => vec![], + Role::User => vec![json!({ + "role": "user", + "content": msg.text.clone().unwrap_or_default(), + })], + Role::Assistant => { + if !msg.tool_calls.is_empty() { + let calls: Vec = msg + .tool_calls + .iter() + .map(|c| { + json!({ + "function": { "name": c.name, "arguments": c.arguments }, + }) + }) + .collect(); + vec![json!({ "role": "assistant", "content": "", "tool_calls": calls })] + } else { + vec![json!({ + "role": "assistant", + "content": msg.text.clone().unwrap_or_default(), + })] + } + } + Role::Tool => msg + .tool_results + .iter() + .map(|r| json!({ "role": "tool", "content": r.content })) + .collect(), + } +} + +/// Process-lifetime cache for `model_supports_tools`, keyed by +/// `"{base_url}::{model}"` so two different stub servers in the same test +/// binary — or a stub and the real node — never collide on the same +/// model name. +static TOOL_CAPABILITY_CACHE: OnceLock>> = OnceLock::new(); + +/// Query Ollama's own model-info endpoint (`/api/show`) for whether +/// `model` is tagged tool-capable, and cache the answer for the process +/// lifetime. This is what turns 13-AI-SPEC.md §4's `[ASSUMED]` note about +/// `qwen2.5-coder` into a runtime fact: a model that cannot call tools is +/// never silently used as the assistant's primary. Fails CLOSED — an +/// unreachable node, a malformed response, or a `capabilities` list that +/// doesn't mention `"tools"` all report `false`, so `select_backend` falls +/// through to Claude rather than handing tools to a model that cannot use +/// them. +pub async fn model_supports_tools(base_url: &str, model: &str) -> bool { + let cache_key = format!("{base_url}::{model}"); + let cache = TOOL_CAPABILITY_CACHE.get_or_init(|| StdMutex::new(HashMap::new())); + if let Some(&cached) = cache + .lock() + .expect("tool capability cache poisoned") + .get(&cache_key) + { + return cached; + } + let supports = probe_model_supports_tools(base_url, model).await; + cache + .lock() + .expect("tool capability cache poisoned") + .insert(cache_key, supports); + supports +} + +async fn probe_model_supports_tools(base_url: &str, model: &str) -> bool { + let client = match reqwest::Client::builder() + .timeout(OLLAMA_HTTP_TIMEOUT) + .build() + { + Ok(c) => c, + Err(_) => return false, + }; + let url = format!("{base_url}{OLLAMA_SHOW_URL}"); + let resp = match client + .post(&url) + .json(&json!({ "model": model })) + .send() + .await + { + Ok(r) if r.status().is_success() => r, + _ => return false, + }; + let body: Value = match resp.json().await { + Ok(v) => v, + Err(_) => return false, + }; + body.get("capabilities") + .and_then(|c| c.as_array()) + .map(|arr| arr.iter().any(|v| v.as_str() == Some("tools"))) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use tokio::net::TcpListener; + use tokio::sync::Mutex as AsyncMutex; + + /// One HTTP request captured by the stub server: the path hit and the + /// parsed JSON body sent. + #[derive(Debug, Clone)] + struct CapturedRequest { + path: String, + body: Value, + } + + /// A minimal local HTTP stub standing in for Ollama's `/api/chat` and + /// `/api/show` endpoints. No mock-HTTP crate exists in this workspace + /// (verified against `Cargo.toml`) — built directly on `hyper` + /// (already in-tree, `full` feature), matching `server.rs`'s own + /// `Http::new().serve_connection` pattern. + struct StubOllama { + base_url: String, + captured: Arc>>, + response: Arc>, + } + + impl StubOllama { + /// Start the stub; `response` is returned verbatim (as JSON) for + /// every request regardless of path — tests that care which path + /// was hit read `captured()` afterwards. + async fn start(response: Value) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind stub"); + let addr = listener.local_addr().expect("local_addr"); + let captured = Arc::new(AsyncMutex::new(Vec::new())); + let response = Arc::new(AsyncMutex::new(response)); + let captured_bg = captured.clone(); + let response_bg = response.clone(); + tokio::spawn(async move { + loop { + let (stream, _) = match listener.accept().await { + Ok(v) => v, + Err(_) => break, + }; + let captured = captured_bg.clone(); + let response = response_bg.clone(); + tokio::spawn(async move { + let service = + hyper::service::service_fn(move |req: hyper::Request| { + let captured = captured.clone(); + let response = response.clone(); + async move { + let path = req.uri().path().to_string(); + let body_bytes = hyper::body::to_bytes(req.into_body()) + .await + .unwrap_or_default(); + let body: Value = + serde_json::from_slice(&body_bytes).unwrap_or(Value::Null); + captured.lock().await.push(CapturedRequest { path, body }); + let resp_body = response.lock().await.clone(); + let resp = hyper::Response::builder() + .status(200) + .header("content-type", "application/json") + .body(hyper::Body::from(resp_body.to_string())) + .expect("static response builds"); + Ok::<_, std::convert::Infallible>(resp) + } + }); + let _ = hyper::server::conn::Http::new() + .http1_keep_alive(false) + .serve_connection(stream, service) + .await; + }); + } + }); + Self { + base_url: format!("http://{addr}"), + captured, + response, + } + } + + async fn captured(&self) -> Vec { + self.captured.lock().await.clone() + } + + async fn set_response(&self, response: Value) { + *self.response.lock().await = response; + } + } + + fn chat_response_text(text: &str) -> Value { + json!({ + "model": "test-model", + "message": { "role": "assistant", "content": text }, + "done": true, + }) + } + + fn chat_response_with_tool_calls(calls: Vec<(&str, Value)>) -> Value { + json!({ + "model": "test-model", + "message": { + "role": "assistant", + "content": "", + "tool_calls": calls + .into_iter() + .map(|(name, args)| json!({ "function": { "name": name, "arguments": args } })) + .collect::>(), + }, + "done": true, + }) + } + + fn show_response(capabilities: &[&str]) -> Value { + json!({ "capabilities": capabilities }) + } + + /// Behavior: a turn with tools produces a request to the chat endpoint + /// — never the older single-shot prompt endpoint. + #[tokio::test] + async fn ollama_uses_chat_endpoint_not_generate() { + let stub = StubOllama::start(chat_response_text("hello")).await; + let backend = OllamaBackend::new(stub.base_url.clone(), "test-model".to_string()); + let result = backend.send("system", &[], &[]).await.expect("send"); + assert!(matches!(result, BackendTurn::Text(t) if t == "hello")); + let reqs = stub.captured().await; + assert_eq!(reqs.len(), 1); + assert_eq!(reqs[0].path, OLLAMA_CHAT_URL); + } + + /// Behavior: a response containing tool calls maps to + /// `BackendTurn::ToolCalls`, with each call assigned a non-empty, + /// unique-within-the-turn id even though the wire response carries + /// none. + #[tokio::test] + async fn tool_calls_get_synthesized_ids() { + let stub = StubOllama::start(chat_response_with_tool_calls(vec![ + ("app_restart", json!({ "app_id": "immich" })), + ("app_restart", json!({ "app_id": "gitea" })), + ])) + .await; + let backend = OllamaBackend::new(stub.base_url.clone(), "test-model".to_string()); + let result = backend.send("system", &[], &[]).await.expect("send"); + let BackendTurn::ToolCalls(calls) = result else { + panic!("expected tool calls") + }; + assert_eq!(calls.len(), 2); + assert!(!calls[0].id.is_empty(), "id must not be empty"); + assert!(!calls[1].id.is_empty(), "id must not be empty"); + assert_ne!( + calls[0].id, calls[1].id, + "ids must be unique within the turn" + ); + } + + /// Behavior: tool-call arguments arrive as an already-parsed object + /// and are passed through without a second string-parse. + #[tokio::test] + async fn arguments_object_is_not_string_parsed() { + let args = json!({ "app_id": "immich", "nested": { "a": 1, "b": [1, 2, 3] } }); + let stub = StubOllama::start(chat_response_with_tool_calls(vec![( + "app_restart", + args.clone(), + )])) + .await; + let backend = OllamaBackend::new(stub.base_url.clone(), "test-model".to_string()); + let result = backend.send("system", &[], &[]).await.expect("send"); + let BackendTurn::ToolCalls(calls) = result else { + panic!("expected tool calls") + }; + assert_eq!( + calls[0].arguments, args, + "arguments must pass through as the same parsed object, not a re-parsed string" + ); + } + + /// Behavior: a response with only text maps to `BackendTurn::Text`. + #[tokio::test] + async fn text_only_response_maps_to_backend_turn_text() { + let stub = StubOllama::start(chat_response_text("disk space report generated")).await; + let backend = OllamaBackend::new(stub.base_url.clone(), "test-model".to_string()); + let result = backend.send("system", &[], &[]).await.expect("send"); + assert!(matches!(result, BackendTurn::Text(t) if t == "disk space report generated")); + } + + /// Behavior: every turn is requested non-streaming, and the + /// generation-length cap is always set explicitly. + #[tokio::test] + async fn request_is_non_streaming_with_explicit_generation_cap() { + let stub = StubOllama::start(chat_response_text("ok")).await; + let backend = OllamaBackend::new(stub.base_url.clone(), "test-model".to_string()); + backend.send("system", &[], &[]).await.expect("send"); + let reqs = stub.captured().await; + assert_eq!( + reqs[0].body.get("stream").and_then(|v| v.as_bool()), + Some(false), + "every turn must be requested non-streaming" + ); + assert_eq!( + reqs[0] + .body + .get("options") + .and_then(|o| o.get("num_predict")) + .and_then(|v| v.as_u64()), + Some(OLLAMA_NUM_PREDICT as u64), + "the generation cap must be set explicitly on every request" + ); + } + + /// The request carries a `messages` array (system + history) and a + /// `tools` array — never a bare prompt string. + #[tokio::test] + async fn request_carries_messages_and_tools_arrays() { + let stub = StubOllama::start(chat_response_text("ok")).await; + let backend = OllamaBackend::new(stub.base_url.clone(), "test-model".to_string()); + let tool = crate::assistant::tools::system_disk_status_tool(); + let history = vec![ChatMessage { + role: Role::User, + text: Some("hi".to_string()), + tool_calls: vec![], + tool_results: vec![], + }]; + backend + .send("sys prompt", &[tool], &history) + .await + .expect("send"); + let reqs = stub.captured().await; + let body = &reqs[0].body; + let messages = body + .get("messages") + .and_then(|m| m.as_array()) + .expect("messages array present"); + assert!( + messages.len() >= 2, + "expected at least the system message and the user message: {messages:?}" + ); + let tools = body + .get("tools") + .and_then(|t| t.as_array()) + .expect("tools array present"); + assert!(!tools.is_empty()); + } + + /// `model_supports_tools` reads the `capabilities` array from + /// `/api/show` — a tool-capable model reports true. + #[tokio::test] + async fn model_supports_tools_reads_capabilities_from_api_show() { + let stub = StubOllama::start(show_response(&["completion", "tools"])).await; + let supports = model_supports_tools(&stub.base_url, "unique-model-a").await; + assert!(supports); + let reqs = stub.captured().await; + assert_eq!(reqs[0].path, OLLAMA_SHOW_URL); + } + + /// Behavior: `select_backend` falls through to Claude when the + /// configured model is reachable but not tool-capable — the mechanism + /// this proves is that `model_supports_tools` reports `false` for such + /// a model, which is exactly the signal `backends::select_backend` + /// acts on. + #[tokio::test] + async fn non_tool_capable_model_falls_through_to_claude() { + let stub = StubOllama::start(show_response(&["completion"])).await; + let supports = model_supports_tools(&stub.base_url, "unique-model-b").await; + assert!( + !supports, + "a model without the tools capability must report false so select_backend falls through to Claude" + ); + } + + /// `model_supports_tools` caches its answer for the process lifetime — + /// a second call for the same (base_url, model) does not re-probe. + #[tokio::test] + async fn model_supports_tools_caches_for_process_lifetime() { + let stub = StubOllama::start(show_response(&["tools"])).await; + let first = model_supports_tools(&stub.base_url, "unique-model-c").await; + assert!(first); + // Reconfigure the stub to report no tools capability — a cached + // call must NOT re-probe and see this change. + stub.set_response(show_response(&["completion"])).await; + let second = model_supports_tools(&stub.base_url, "unique-model-c").await; + assert!( + second, + "the process-lifetime cache must not re-probe once an answer is cached" + ); + } + + /// An unreachable Ollama returns a transport error from `send()` + /// rather than panicking — `backends::select_backend`'s `FallbackChain` + /// is what turns this into a fall-through, but this adapter's own + /// contract is simply: propagate the error, never crash. + #[tokio::test] + async fn unreachable_ollama_returns_transport_error_not_panic() { + let backend = OllamaBackend::new("http://127.0.0.1:1".to_string(), "m".to_string()); + let result = backend.send("sys", &[], &[]).await; + assert!(result.is_err()); + } +} diff --git a/core/archipelago/src/assistant/mod.rs b/core/archipelago/src/assistant/mod.rs index 2566867a..5ca85620 100644 --- a/core/archipelago/src/assistant/mod.rs +++ b/core/archipelago/src/assistant/mod.rs @@ -263,7 +263,9 @@ pub fn build_system_prompt(visible_tools: &[tools::ToolDef]) -> String { /// 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. +/// backend per D-04's chain (Ollama first, Claude fallback), and runs it to +/// a final answer. 13-10 Task 2 adds D-08 history persistence on top of +/// this. pub async fn chat( handler: Arc, caller: CallerScope, @@ -273,7 +275,8 @@ pub async fn chat( let grants = caller.granted_categories(handler.data_dir()).await; let visible_tools = registry.visible_to(&grants); - let backend = backends::select_backend(handler.data_dir()); + let (backend, backend_id) = backends::select_backend(handler.data_dir()).await; + tracing::info!(backend = %backend_id, "assistant.chat: backend selected for this turn"); let system_prompt = build_system_prompt(&visible_tools); @@ -543,7 +546,10 @@ mod tests { let mut second_call = call.clone(); second_call.id = "call-2".to_string(); let second = loop_::execute_tool(&second_call, &ctx).await; - assert!(second.is_error, "a re-asked declined action must be an error"); + assert!( + second.is_error, + "a re-asked declined action must be an error" + ); assert!( second.content.to_lowercase().contains("already declined"), "the refusal must tell the model it was already declined: {}",