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:
co-authored by
Claude Fable 5
parent
a245e5d29f
commit
821d8700ce
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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: {}",
|
||||
|
||||
Reference in New Issue
Block a user