Assistant: warn when an Ollama model has no tool support (#4846) (#4916)

* claim: #4846

* Warn when Ollama models lack tool support (#4846)

Check Ollama model capabilities in the background and cache results per
endpoint and model. Show an advisory warning for models without tools,
expose it through ui.agent_chat, and document the behavior.

Cover capability parsing, warning selection, background probing, caching,
and selection changes with synthetic regression tests.

* Keep the Ollama capability probe out of the web build

The panel never calls it there, so it would only be dead code.

* Wrap the model warning instead of cutting it off

In a narrow panel the end of the sentence, the part that says what to do, was truncated.

* Fix AgentConfig default after rebase onto main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move the Ollama no-tools warning into a pending locale file with ja and zh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
raykholo
2026-10-25 21:37:30 +00:00
committed by GitHub
parent 39951a22ff
commit 4355d117ef
8 changed files with 247 additions and 1 deletions
@@ -0,0 +1,5 @@
{
"en": { "ui.agent_chat_model_no_tools": "{model} can't use SolidCraft's tools, so it can only chat. Pick a model with tool support." },
"ja": { "ui.agent_chat_model_no_tools": "{model} は SolidCraft のツールを使えず、チャットのみ可能です。ツール対応のモデルを選んでください。" },
"zh": { "ui.agent_chat_model_no_tools": "{model} 无法使用 SolidCraft 的工具,只能聊天。请选择支持工具的模型。" }
}
+3
View File
@@ -106,6 +106,8 @@ pub struct AgentConfig {
/// API: the endpoint (`…/v1`) and model.
pub base_url: String,
pub model: String,
/// Added with the Ollama preset (even if its display name changes).
pub ollama: bool,
/// API: a key is stored for it in the system keychain.
pub key: bool,
/// API: how long one model request may take, in seconds (clamped to
@@ -122,6 +124,7 @@ impl Default for AgentConfig {
command: String::new(),
base_url: String::new(),
model: String::new(),
ollama: false,
key: false,
request_timeout_s: DEFAULT_REQUEST_TIMEOUT_S,
}
+83
View File
@@ -352,10 +352,93 @@ fn detect_in(dirs: &[PathBuf], home: Option<&Path>) -> Detection {
d
}
/// Query only the endpoint's origin; OpenAI-compatible paths such as `/v1` are unrelated.
#[cfg(not(target_arch = "wasm32"))]
fn api_origin(base_url: &str) -> Option<String> {
let (scheme, rest) = base_url.trim().split_once("://")?;
if !matches!(scheme, "http" | "https") {
return None;
}
let authority = rest.split(['/', '?', '#']).next()?;
if authority.is_empty() || authority.contains(['@', ' ']) || authority.len() > 2048 {
return None;
}
Some(format!("{scheme}://{authority}"))
}
/// Blocking, best-effort Ollama probe. Call only on a background thread, without API keys.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn ollama_tools(http: &super::backend::HttpFn, base_url: &str, model: &str, known_ollama: bool) -> Option<bool> {
use super::backend::HttpRequest;
let origin = api_origin(base_url)?;
let request = |method, path: &str, body| {
let (status, bytes) = http(&HttpRequest {
method,
url: format!("{origin}/api/{path}"),
headers: if method == "POST" { vec![("Content-Type".into(), "application/json".into())] } else { Vec::new() },
body,
timeout_s: 5,
})
.ok()?;
if !(200..300).contains(&status) || bytes.len() > 4 * 1024 * 1024 {
return None;
}
serde_json::from_slice::<serde_json::Value>(&bytes).ok()
};
if !known_ollama {
let version = request("GET", "version", None)?;
version.get("version")?.as_str()?;
}
let body = serde_json::to_vec(&serde_json::json!({"model": model})).ok()?;
tools_capability(&request("POST", "show", Some(body))?)
}
/// Tool support is unknown unless Ollama returns a capabilities list of strings.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn tools_capability(value: &serde_json::Value) -> Option<bool> {
let capabilities = value.get("capabilities")?.as_array()?;
if capabilities.iter().any(|capability| !capability.is_string()) {
return None;
}
Some(capabilities.iter().any(|capability| capability.as_str() == Some("tools")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ollama_capabilities_distinguish_tools_chat_and_unknown() {
use serde_json::json;
assert_eq!(tools_capability(&json!({"capabilities": ["completion", "tools"]})), Some(true));
assert_eq!(tools_capability(&json!({"capabilities": ["completion"]})), Some(false));
assert_eq!(tools_capability(&json!({"capabilities": []})), Some(false));
for value in [json!({}), json!({"capabilities": null}), json!({"capabilities": [false]})] {
assert_eq!(tools_capability(&value), None);
}
assert_eq!(api_origin("http://[::1]:11434/proxy/v1/").as_deref(), Some("http://[::1]:11434"));
assert_eq!(api_origin("file:///tmp/model"), None);
let http: super::super::backend::HttpFn = std::sync::Arc::new(|request| {
assert_eq!(request.timeout_s, 5);
match request.method {
"GET" => {
assert_eq!(request.url, "http://localhost:11434/api/version");
Ok((200, br#"{"version":"0.0"}"#.to_vec()))
}
"POST" => {
assert_eq!(request.url, "http://localhost:11434/api/show");
assert_eq!(serde_json::from_slice::<serde_json::Value>(request.body.as_ref().unwrap()).unwrap(), json!({"model":"chat:4b"}));
Ok((200, br#"{"capabilities":["completion"]}"#.to_vec()))
}
_ => Err("unexpected HTTP method".into()),
}
});
assert_eq!(ollama_tools(&http, "http://localhost:11434/custom/v1", "chat:4b", false), Some(false));
assert_eq!(ollama_tools(&http, "http://localhost:11434/v1", "chat:4b", true), Some(false));
let unavailable: super::super::backend::HttpFn = std::sync::Arc::new(|_| Err("unavailable".into()));
assert_eq!(ollama_tools(&unavailable, "http://localhost:11434/v1", "chat:4b", false), None);
}
#[test]
fn a_program_is_found_in_the_first_folder_that_has_it() {
let root = std::env::temp_dir().join(format!("sc-agent-find-{}", std::process::id()));
+3
View File
@@ -1614,6 +1614,8 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) {
start_turn(app, text, image, at, files);
}
}
#[cfg(not(target_arch = "wasm32"))]
settings::poll_capabilities(app, ctx);
pump_tools(app);
if !app.agent_chat.held.is_empty() || app.agent_chat.state() == State::Busy {
ctx.request_repaint_after(std::time::Duration::from_millis(250));
@@ -1964,6 +1966,7 @@ pub fn status(app: &SolveApp) -> Value {
"model": c.model,
"providers": model_providers(app).iter().map(|(name, count)| json!({"name": name, "count": count})).collect::<Vec<_>>(),
"provider": shown_provider(app),
"modelWarning": settings::model_warning(app),
"modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::<Vec<_>>(),
"mode": c.mode,
"open": c.open,
+3
View File
@@ -1163,6 +1163,9 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
});
if let Some(warning) = settings::model_warning(app) {
ui.add(egui::Label::new(RichText::new(warning).color(t.warning).size(t.font_small)).wrap());
}
ui.horizontal(|ui| {
let b = if app.agent_chat.state().running() {
ui_kit::button(ui, tr("ui.agent_chat_stop"), ui_kit::Button::Normal).on_hover_text(tr("ui.agent_chat_stop_tip"))
+142
View File
@@ -54,6 +54,92 @@ impl Test {
pub struct State {
pub add: Option<AddState>,
pub tests: std::collections::HashMap<String, Test>,
#[cfg(not(target_arch = "wasm32"))]
capabilities: std::collections::HashMap<(String, String), Option<bool>>,
#[cfg(not(target_arch = "wasm32"))]
checking: Option<CapabilityCheck>,
}
#[cfg(not(target_arch = "wasm32"))]
struct CapabilityCheck {
key: (String, String),
rx: std::sync::mpsc::Receiver<Option<bool>>,
}
/// Poll one bounded background probe at a time. Failures are cached too.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn poll_capabilities(app: &mut SolveApp, ctx: &egui::Context) {
use std::sync::mpsc::TryRecvError;
let state = &mut app.agent_chat.settings;
if let Some(check) = &state.checking {
let result = match check.rx.try_recv() {
Ok(value) => Some(value),
Err(TryRecvError::Disconnected) => Some(None),
Err(TryRecvError::Empty) => None,
};
if let Some(value) = result {
if state.capabilities.len() >= 128 {
state.capabilities.clear();
}
state.capabilities.insert(check.key.clone(), value);
state.checking = None;
}
}
if state.checking.is_some() {
return;
}
let Some((key, known)) = capability_selection(app) else { return };
if app.agent_chat.settings.capabilities.contains_key(&key) {
return;
}
let Some(http) = app.services.agent_http.clone() else { return };
if app.agent_chat.settings.capabilities.len() >= 128 {
app.agent_chat.settings.capabilities.clear();
}
let (tx, rx) = std::sync::mpsc::channel();
let query = key.clone();
let wake = ctx.clone();
if std::thread::Builder::new()
.name("model-capabilities".into())
.spawn(move || {
let _ = tx.send(discover::ollama_tools(&http, &query.0, &query.1, known));
wake.request_repaint();
})
.is_ok()
{
app.agent_chat.settings.checking = Some(CapabilityCheck { key, rx });
} else {
app.agent_chat.settings.capabilities.insert(key, None);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn capability_selection(app: &SolveApp) -> Option<((String, String), bool)> {
let entry = agents::current(app)?;
if entry.kind != Kind::Api {
return None;
}
let model = if app.agent_chat.backend.is_none() || app.agent_chat.model.is_empty() { entry.model } else { app.agent_chat.model.clone() };
if model.is_empty() || model.len() > 4096 || entry.base_url.len() > 4096 {
return None;
}
let known = app.preferences.agents.iter().any(|a| a.id == entry.id && (a.ollama || a.name.eq_ignore_ascii_case("Ollama")));
Some(((entry.base_url, model), known))
}
/// The same advisory text is used by the panel and the control channel.
pub(super) fn model_warning(app: &SolveApp) -> Option<String> {
#[cfg(not(target_arch = "wasm32"))]
if let Some((key, _)) = capability_selection(app) {
return capability_warning(&key.1, app.agent_chat.settings.capabilities.get(&key).copied().flatten());
}
let _ = app;
None
}
#[cfg(not(target_arch = "wasm32"))]
fn capability_warning(model: &str, tools: Option<bool>) -> Option<String> {
(tools == Some(false)).then(|| trf("ui.agent_chat_model_no_tools", &[("model", model)]))
}
/// Open the Add agent dialog.
@@ -645,6 +731,7 @@ fn add_agent(app: &mut SolveApp) -> Result<(), String> {
}
AddKind::Api => {
c.kind = s.protocol.kind();
c.ollama = s.protocol == Protocol::OpenAi && s.preset == 0;
c.base_url = agents::normalize_url(&s.base_url)?;
c.model = s.model.trim().to_string();
c.request_timeout_s = s.request_timeout_s.clamp(agents::MIN_REQUEST_TIMEOUT_S, agents::MAX_REQUEST_TIMEOUT_S);
@@ -676,6 +763,61 @@ mod card_tests {
use crate::scenario::{self, Harness};
use serde_json::json;
#[test]
fn cached_model_warning_follows_endpoint_model_and_agent_selection() {
let mut h = Harness::new();
h.app.agent_chat.detection = Some(Default::default());
h.app.preferences.agents.push(AgentConfig {
id: "local".into(),
kind: Kind::Api,
base_url: "http://localhost:11434/v1".into(),
model: "chat:4b".into(),
..Default::default()
});
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::time::{Duration, Instant};
let calls = Arc::new(AtomicUsize::new(0));
let count = calls.clone();
let ui_thread = std::thread::current().id();
h.app.services.agent_http = Some(Arc::new(move |request| {
assert_ne!(std::thread::current().id(), ui_thread);
count.fetch_add(1, Ordering::SeqCst);
Ok((200, if request.method == "GET" { br#"{"version":"0.0"}"#.to_vec() } else { br#"{"capabilities":["completion"]}"#.to_vec() }))
}));
poll_capabilities(&mut h.app, &h.ctx);
let deadline = Instant::now() + Duration::from_secs(2);
while h.app.agent_chat.settings.checking.is_some() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(1));
poll_capabilities(&mut h.app, &h.ctx);
}
assert!(h.app.agent_chat.settings.checking.is_none());
assert_eq!(calls.load(Ordering::SeqCst), 2);
poll_capabilities(&mut h.app, &h.ctx);
assert_eq!(calls.load(Ordering::SeqCst), 2, "cached selections do not probe again");
assert_eq!(status(&h.app)["modelWarning"], capability_warning("chat:4b", Some(false)).unwrap());
h.app.preferences.agents[0].model = "tools:8b".into();
assert!(status(&h.app)["modelWarning"].is_null());
h.app.preferences.agents[0].model = "chat:4b".into();
h.app.preferences.agents[0].base_url = "http://localhost:1234/v1".into();
assert!(status(&h.app)["modelWarning"].is_null());
h.app.preferences.agents[0].base_url = "http://localhost:11434/v1".into();
h.app.preferences.agents[0].kind = Kind::Acp;
assert!(status(&h.app)["modelWarning"].is_null());
}
#[test]
fn model_warning_only_for_explicitly_missing_tools() {
assert_eq!(
capability_warning("local:4b", Some(false)).as_deref(),
Some("local:4b can't use SolidCraft's tools, so it can only chat. Pick a model with tool support.")
);
assert_eq!(capability_warning("local:4b", Some(true)), None);
assert_eq!(capability_warning("local:4b", None), None);
}
#[test]
fn add_agent_card_retains_kind_handles_draft_and_validation() {
let mut h = Harness::new();
+7
View File
@@ -224,6 +224,13 @@ Grok Build also supports `XAI_API_KEY` for users who choose API authentication.
endpoint's entry under **Agents**, where a change applies from the next request. A request that
runs out of time ends the message with the limit it hit and where to raise it.
For Ollama, choosing a model checks its reported capabilities in the background. If its
capabilities list lacks `tools`, a warning below the model picker explains that the model can
only chat and suggests choosing one with tool support. Results are cached in memory per base URL and
model, with a limit of 128 entries. SolidCraft recognises Ollama through `/api/version` at the
endpoint's origin or the Ollama preset. Failed checks and older servers without a capabilities
field show no warning. The check never blocks sending a message.
### An agent inside WSL (Windows)
An agent installed inside WSL keeps its own settings, skills and sign-in there. Add it as a
+1 -1
View File
@@ -253,7 +253,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox.
| `engine.execute` → `browser.expand_all` | `path?`: occurrence ids; `component?`: id; omit both for the whole tree | Open every Browser row under that scope, including folders that start closed (Origin, Named Views, Features). Display only: not saved in the design, not an undo step. |
| `engine.execute` → `browser.collapse_all` | same | Close those rows. The design row stays open when the whole tree is collapsed. |
| `engine.execute` → `view.history_layout` | `width?`, `dock?`: `bottom`, `left`, `under_browser`, `collapsed?` | Resize/dock the history without opening a dialog or changing the design. Contents respond to measured width; browser-stacked history shares its width and splits height equally. One folded header gives the expanded card the rest; two folded headers stay at the top. |
| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp`, `api` or `anthropic` (`api` is OpenAI-compatible, `anthropic` Anthropic-compatible), `command` or `base_url` + `model` + `request_timeout_s?` (seconds one model request may take, 300 by default, rounded and clamped to 30 to 3600)}; no keys), `add_dialog?` (`installed`, `custom`, `api`, `anthropic`, or `false` to close), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `provider?` (which provider's models the model picker lists, when they come from several), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `attach?`: a file's path (a picture, a text file or a CAD file, for the next message; CAD files are imported at once), `remove_file?` (index of a file on the next message), `send?`: text (files alone make a message too), `attach_view?`, `attach_selection?`, `pick?` (true: open the context picker), `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `providers` (`name` and `count` of each, empty with one provider), `provider` (the one listed), `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages, with their `files`), `attachments` (the files on the next message, by name), `loading` (pictures still being read for it, by name), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp`, `api` or `anthropic` (`api` is OpenAI-compatible, `anthropic` Anthropic-compatible), `command` or `base_url` + `model` + `request_timeout_s?` (seconds one model request may take, 300 by default, rounded and clamped to 30 to 3600)}; no keys), `add_dialog?` (`installed`, `custom`, `api`, `anthropic`, or `false` to close), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `provider?` (which provider's models the model picker lists, when they come from several), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `attach?`: a file's path (a picture, a text file or a CAD file, for the next message; CAD files are imported at once), `remove_file?` (index of a file on the next message), `send?`: text (files alone make a message too), `attach_view?`, `attach_selection?`, `pick?` (true: open the context picker), `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `modelWarning` (the model capability warning text, or `null` when unknown or tool support is reported; checked asynchronously and never blocks sending), `providers` (`name` and `count` of each, empty with one provider), `provider` (the one listed), `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages, with their `files`), `attachments` (the files on the next message, by name), `loading` (pictures still being read for it, by name), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
| `ui.resize` | `width`, `height` | |
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |