feat(13-10): Ollama tool-calling backend, first in the D-04 chain

backends/ollama.rs implements the Backend trait against Ollama's
POST /api/chat (messages + tools arrays, message.tool_calls response) --
never mesh/listener/assist.rs::call_ollama's older single-shot prompt
endpoint, which has no tool-calling support at all. Ollama's per-call
tool-call ids (absent on the wire) are synthesized; its already-parsed
function.arguments object is passed through without a second string-parse
(the OpenAI-shape normalization would be wrong here). Every request sets
an explicit generation-length cap and runs non-streaming.

model_supports_tools queries Ollama's /api/show and caches the answer for
the process lifetime, turning AI-SPEC's [ASSUMED] note about
qwen2.5-coder's tool capability into a runtime fact: a non-tool-capable or
unreachable Ollama falls through to Claude with a logged reason, never a
silent tools-free degrade.

select_backend (backends/mod.rs) is now async and reuses the existing
detect_ollama() probe (mesh::assistant, bumped to pub(crate) for this
reuse) rather than re-probing. A new FallbackChain wraps the Ollama leg so
a transport error mid-turn falls through to Claude for that same call
instead of failing the turn outright.

13 new tests under assistant::backends::{ollama,}::tests::, exercised
against a local hyper-based HTTP stub (no mock-HTTP crate exists in this
workspace). Full assistant:: suite: 42/42 (29 baseline + 13 new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-05 17:42:24 -04:00
co-authored by Claude Fable 5
parent a245e5d29f
commit 821d8700ce
6 changed files with 817 additions and 15 deletions
@@ -161,7 +161,10 @@ impl RpcHandler {
}
/// Probe the local Ollama HTTP API; return (detected, model_names).
async fn detect_ollama() -> (bool, Vec<String>) {
///
/// `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<String>) {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
+4 -1
View File
@@ -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;
+6 -3
View File
@@ -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};
+194 -7
View File
@@ -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<BackendTurn>;
}
/// 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<dyn Backend>,
primary_id: BackendId,
secondary: Box<dyn Backend>,
}
#[async_trait]
impl Backend for FallbackChain {
async fn send(
&self,
system: &str,
tools: &[ToolDef],
history: &[ChatMessage],
) -> Result<BackendTurn> {
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<dyn Backend> {
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<dyn Backend>, 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<BackendTurn> {
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<BackendTurn> {
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"
);
}
}
@@ -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<BackendTurn> {
let mut messages: Vec<Value> = vec![json!({ "role": "system", "content": system })];
messages.extend(history.iter().flat_map(message_to_wire));
let ollama_tools: Vec<Value> = 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::<String>()
);
}
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<ToolCall> = 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<Value> {
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<Value> = 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<StdMutex<HashMap<String, bool>>> = 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<AsyncMutex<Vec<CapturedRequest>>>,
response: Arc<AsyncMutex<Value>>,
}
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<hyper::Body>| {
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<CapturedRequest> {
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::<Vec<_>>(),
},
"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());
}
}
+9 -3
View File
@@ -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<RpcHandler>,
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: {}",