From 6c504c7edd00ae84f74446a9b7b119f54207b67c Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Wed, 3 Jun 2026 00:52:20 +0530 Subject: [PATCH 1/2] fix(inference/ollama): use prompt-guided tool calling so skills work on Ollama (#3098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ollama's `/v1/chat/completions` OpenAI-compat endpoint rejects the `tools` parameter with HTTP 400 "unsupported parameter: tools" for many models. The existing retry path in `compatible.rs` detects this and retries with `tools: None` — i.e. strips tools entirely. The model then replies in natural language but can't actually invoke any tool. Skills are markdown instruction documents the agent reads on demand; many skills tell the model to "use the file_read tool" or "run shell X". When tools are silently stripped, the model talks about doing the thing but doesn't do it — sub-issue 3 of #3098: "skills not running." The fix: report `native_tool_calling = false` from the Ollama branch of `OpenAiCompatibleProvider::capabilities()`. The agent harness then embeds tool specs as text in the system prompt and parses tool calls out of the response (the `ToolsPayload::PromptGuided` path), which any chat model can follow — no upstream `tools` array, no 400, no silent retry-without-tools. Implementation: - Add `native_tool_calling: bool` field on `OpenAiCompatibleProvider` (defaults to `true`, preserving every existing call site). - Add `with_native_tool_calling(bool)` builder. - `capabilities()` returns the stored flag instead of hardcoded `true`. - `factory::make_ollama_provider` constructs the provider directly and calls `.with_native_tool_calling(false)`. LM Studio, OpenHuman backend, BYOK cloud providers (OpenAI/Anthropic/etc.), and direct `OpenAiCompatibleProvider::new` callers are unaffected — they keep the default `true`. Tests: - 3 new unit tests in `compatible_tests.rs` pin the builder behavior: default true, `with_native_tool_calling(false)` flips it, repeated calls are idempotent. - 2 new factory tests pin the integration: `ollama:` reports `native_tool_calling=false`; `lmstudio:` keeps it `true`. Scope: this fix benefits every Ollama user (desktop, all channels), not just Telegram. With PR #3217, Telegram now reliably routes to Ollama when the user picks it — making this latent bug actively visible to those users. Closes sub-issue 3 of #3098. --- .../inference/provider/compatible.rs | 21 +++++++++- .../inference/provider/compatible_tests.rs | 33 +++++++++++++++ src/openhuman/inference/provider/factory.rs | 22 ++++++---- .../inference/provider/factory_tests.rs | 42 +++++++++++++++++++ 4 files changed, 110 insertions(+), 8 deletions(-) diff --git a/src/openhuman/inference/provider/compatible.rs b/src/openhuman/inference/provider/compatible.rs index 81406264bd..10616d3b58 100644 --- a/src/openhuman/inference/provider/compatible.rs +++ b/src/openhuman/inference/provider/compatible.rs @@ -80,6 +80,14 @@ pub struct OpenAiCompatibleProvider { /// carries an `@` suffix (e.g. `"openai:gpt-4o@0.2"`). The /// `temperature_unsupported_models` glob filter still applies after. pub(crate) temperature_override: Option, + /// Value reported by `capabilities().native_tool_calling`. Defaults to + /// `true` because most OpenAI-compatible providers (OpenAI, Anthropic + /// adapters, GLM, Groq, Mistral, OpenHuman backend, …) implement the + /// `tools` parameter correctly. The factory flips this to `false` for + /// Ollama (sub-issue 3 of #3098), whose OpenAI-compat endpoint returns + /// HTTP 400 on `tools` for many models — making prompt-guided text + /// tool specs the only path that works across the Ollama model zoo. + native_tool_calling: bool, } /// How the provider expects the API key to be sent. @@ -195,9 +203,20 @@ impl OpenAiCompatibleProvider { emit_openhuman_thread_id: false, temperature_unsupported_models: Vec::new(), temperature_override: None, + native_tool_calling: true, } } + /// Toggle whether this provider advertises native (OpenAI-style) tool + /// calling to the agent harness. The default is `true`; set to `false` + /// for providers whose `/v1/chat/completions` endpoint rejects the + /// `tools` parameter — the harness will then embed tool specs in the + /// system prompt and parse calls out of the response text instead. + pub fn with_native_tool_calling(mut self, enabled: bool) -> Self { + self.native_tool_calling = enabled; + self + } + /// Set the list of model glob patterns for which temperature must be /// omitted from request bodies. Called by the provider factory to /// propagate `config.temperature_unsupported_models`. @@ -1248,7 +1267,7 @@ impl OpenAiCompatibleProvider { impl Provider for OpenAiCompatibleProvider { fn capabilities(&self) -> crate::openhuman::inference::provider::traits::ProviderCapabilities { crate::openhuman::inference::provider::traits::ProviderCapabilities { - native_tool_calling: true, + native_tool_calling: self.native_tool_calling, vision: false, } } diff --git a/src/openhuman/inference/provider/compatible_tests.rs b/src/openhuman/inference/provider/compatible_tests.rs index 5fb8cfd163..0642f523f7 100644 --- a/src/openhuman/inference/provider/compatible_tests.rs +++ b/src/openhuman/inference/provider/compatible_tests.rs @@ -1303,6 +1303,39 @@ fn capabilities_reports_native_tool_calling() { assert!(caps.native_tool_calling); } +// Sub-issue 3 of #3098: Ollama's OpenAI-compat endpoint silently rejects the +// `tools` parameter for many models, so we must let the factory opt the +// Ollama provider out of native tool calling. The agent harness then falls +// back to prompt-guided tool specs (embedded in the system prompt) which +// any chat model can follow. The builder defaults to enabled so cloud +// providers (OpenAI, BYOK slugs, OpenHuman backend) are unaffected. + +#[test] +fn with_native_tool_calling_false_disables_capability() { + let p = make_provider("test", "https://example.com", None).with_native_tool_calling(false); + let caps = ::capabilities(&p); + assert!( + !caps.native_tool_calling, + "capabilities() must mirror the builder override; this is the gate the agent harness uses to decide between native vs prompt-guided tool specs" + ); +} + +#[test] +fn with_native_tool_calling_true_preserves_default() { + let p = make_provider("test", "https://example.com", None).with_native_tool_calling(true); + let caps = ::capabilities(&p); + assert!(caps.native_tool_calling); +} + +#[test] +fn with_native_tool_calling_is_idempotent() { + let p = make_provider("test", "https://example.com", None) + .with_native_tool_calling(false) + .with_native_tool_calling(false); + let caps = ::capabilities(&p); + assert!(!caps.native_tool_calling); +} + #[test] fn tool_specs_convert_to_openai_format() { let specs = vec![crate::openhuman::tools::ToolSpec { diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 20369212f7..1a9cc885aa 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -731,16 +731,24 @@ fn make_ollama_provider( // Ollama does not expose the Responses API (/v1/responses) — passing // `false` prevents a guaranteed-404 fallback attempt and the Sentry // noise it would generate (TAURI-RUST-59Y). - let p = make_openai_compatible_provider_with_config( + // + // Ollama also rejects the OpenAI-style `tools` parameter for many models + // (HTTP 400 "unsupported parameter: tools"), so we disable + // `native_tool_calling` on the provider directly. The agent harness + // then embeds tool specs in the system prompt and parses tool calls + // out of the response text — a format any chat model can follow. + // Skills that depend on tool invocations now work over Ollama + // (sub-issue 3 of #3098). + let provider = OpenAiCompatibleProvider::new_no_responses_fallback( "ollama", &endpoint, - "", + None, CompatAuthStyle::None, - &config.temperature_unsupported_models, - temperature_override, - false, - )?; - Ok((p, model.to_string())) + ) + .with_temperature_unsupported_models(config.temperature_unsupported_models.clone()) + .with_temperature_override(temperature_override) + .with_native_tool_calling(false); + Ok((Box::new(provider), model.to_string())) } /// Build an LM Studio local provider. diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index 19761ec604..25931efad8 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -210,6 +210,48 @@ fn ollama_prefix() { assert_eq!(model, "llama3.1:8b"); } +#[test] +fn ollama_provider_opts_out_of_native_tool_calling() { + // Sub-issue 3 of #3098: Ollama's OpenAI-compat endpoint returns HTTP 400 + // for many models when a `tools` array is sent (the existing detection + // path matches "unsupported parameter: tools"). The retry logic strips + // tools entirely, which silently breaks any skill or workflow that + // depends on tool calls. The factory must build the Ollama provider + // with native tool calling disabled so the agent harness uses the + // prompt-guided text format from the first request. + let config = Config::default(); + let (provider, _model) = create_chat_provider_from_string("chat", "ollama:llama3.2", &config) + .expect("ollama: must build"); + let caps = provider.capabilities(); + assert!( + !caps.native_tool_calling, + "ollama provider must report native_tool_calling=false so the agent harness emits prompt-guided tool specs instead of an OpenAI-style `tools` array" + ); +} + +#[test] +fn lmstudio_provider_keeps_native_tool_calling_enabled() { + // LM Studio's OpenAI-compat endpoint supports the `tools` parameter for + // models that expose function calling. Only Ollama gets opted out by + // default — the LM Studio path stays on the native schema. + let mut config = Config::default(); + config.local_ai.base_url = Some("http://127.0.0.1:1234".to_string()); + let (provider, _model) = + create_chat_provider_from_string("chat", "lmstudio:google/gemma-4-e4b", &config) + .expect("lmstudio: must build"); + assert!( + provider.capabilities().native_tool_calling, + "lmstudio provider must keep native_tool_calling=true; only the ollama branch opts out" + ); +} + +// Note: a BYOK-cloud regression test (e.g. `openai:gpt-4o` keeps +// native_tool_calling=true) would need an `AuthService` with the slug's API +// key seeded. The unit test +// `with_native_tool_calling_true_preserves_default` in compatible_tests.rs +// already pins that the builder leaves the default in place when not +// called, which is what every non-Ollama factory path relies on. + #[test] fn lmstudio_prefix() { let mut config = Config::default(); From 29f4f667e1523ed9be91305ffcf4fc1da33021a9 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Wed, 3 Jun 2026 02:20:32 +0530 Subject: [PATCH 2/2] fix(inference/ollama): mirror native_tool_calling in supports_native_tools (#3098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review on PR #3229 caught that `OpenAiCompatibleProvider` overrides `Provider::supports_native_tools()` with a hardcoded `true` (`compatible.rs:1961`), bypassing the new `native_tool_calling` field. The harness's actual gate for native-vs-prompt-guided tool dispatch lives at `traits.rs:415` and reads `supports_native_tools()`, not `capabilities().native_tool_calling` directly — so the original PR's opt-out flipped the capability signal but the agent harness still sent a `tools` array to Ollama and got HTTP 400. Update the override to return `self.native_tool_calling` so all three signals (`with_native_tool_calling(...)` builder, `capabilities()`, `supports_native_tools()`) agree. Cloud providers default to `true` exactly as before; only the Ollama factory branch now actually routes through the prompt-guided path the original PR intended. Regression test pins the invariant: after `with_native_tool_calling(false)`, both `supports_native_tools()` and `capabilities().native_tool_calling` must return `false`. --- .../inference/provider/compatible.rs | 7 ++++- .../inference/provider/compatible_tests.rs | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/openhuman/inference/provider/compatible.rs b/src/openhuman/inference/provider/compatible.rs index 10616d3b58..a851f59e9e 100644 --- a/src/openhuman/inference/provider/compatible.rs +++ b/src/openhuman/inference/provider/compatible.rs @@ -1959,7 +1959,12 @@ impl Provider for OpenAiCompatibleProvider { } fn supports_native_tools(&self) -> bool { - true + // Must mirror `capabilities().native_tool_calling`. Both signals are + // read by the agent harness (`traits.rs:415`) to decide between an + // OpenAI-style `tools` array and the prompt-guided text fallback; + // letting them disagree would defeat `with_native_tool_calling(false)` + // for the Ollama branch of sub-issue 3 of #3098. + self.native_tool_calling } fn supports_streaming(&self) -> bool { diff --git a/src/openhuman/inference/provider/compatible_tests.rs b/src/openhuman/inference/provider/compatible_tests.rs index 0642f523f7..beb737c975 100644 --- a/src/openhuman/inference/provider/compatible_tests.rs +++ b/src/openhuman/inference/provider/compatible_tests.rs @@ -1336,6 +1336,36 @@ fn with_native_tool_calling_is_idempotent() { assert!(!caps.native_tool_calling); } +/// `supports_native_tools()` is the gate the agent harness reads +/// (`traits.rs:415`) when deciding whether to send tools natively or +/// inject them into the prompt. It MUST agree with +/// `capabilities().native_tool_calling`; otherwise +/// `with_native_tool_calling(false)` silently fails to switch to +/// prompt-guided and Ollama still receives a `tools` array (the exact +/// regression sub-issue 3 of #3098 was meant to fix). +#[test] +fn supports_native_tools_mirrors_capabilities_flag() { + let default = make_provider("test", "https://example.com", None); + assert_eq!( + default.supports_native_tools(), + ::capabilities(&default).native_tool_calling, + "default provider: the two capability signals must match" + ); + assert!(default.supports_native_tools(), "default must remain true"); + + let opted_out = + make_provider("test", "https://example.com", None).with_native_tool_calling(false); + assert_eq!( + opted_out.supports_native_tools(), + ::capabilities(&opted_out).native_tool_calling, + "after with_native_tool_calling(false): the two capability signals must match" + ); + assert!( + !opted_out.supports_native_tools(), + "after with_native_tool_calling(false), supports_native_tools must report false so the harness picks the prompt-guided fallback" + ); +} + #[test] fn tool_specs_convert_to_openai_format() { let specs = vec![crate::openhuman::tools::ToolSpec {