From 28d019be2e5ba7208874c5779aefc1d8f6f07ae7 Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Tue, 11 Nov 2025 17:10:47 +0200 Subject: [PATCH] ollama: Fix tool calling (#42275) Closes #42303 Ollama added tool call identifiers (https://github.com/ollama/ollama/pull/12956) in its latest version [v0.12.10](https://github.com/ollama/ollama/releases/tag/v0.12.10). This broke our json schema and made all tool calls fail. This PR fixes the schema and uses the Ollama provided tool call identifier when available. We remain backwards compatible and still use our own identifier with older versions of Ollama. I added a `TODO` to remove the `Option` around the new field when most users have updated their installations to v0.12.10 or above. Note to reviewer: The fix to this issue should likely get cherry-picked into the next release, since Ollama becomes unusable as an agent without it. Release Notes: - Fixed tool calling when using the latest version of Ollama --- crates/language_models/src/provider/ollama.rs | 47 +++++++-------- crates/ollama/src/ollama.rs | 59 ++++++++++++++++++- 2 files changed, 80 insertions(+), 26 deletions(-) diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index a0aada7d1a7b557e1e5aa07f19dd3e38492fc972..b6870f5f72b08d2ca4decc101deae59b6a56c224 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -381,10 +381,13 @@ impl OllamaLanguageModel { thinking = Some(text) } MessageContent::ToolUse(tool_use) => { - tool_calls.push(OllamaToolCall::Function(OllamaFunctionCall { - name: tool_use.name.to_string(), - arguments: tool_use.input, - })); + tool_calls.push(OllamaToolCall { + id: Some(tool_use.id.to_string()), + function: OllamaFunctionCall { + name: tool_use.name.to_string(), + arguments: tool_use.input, + }, + }); } _ => (), } @@ -575,25 +578,23 @@ fn map_to_language_model_completion_events( } if let Some(tool_call) = tool_calls.and_then(|v| v.into_iter().next()) { - match tool_call { - OllamaToolCall::Function(function) => { - let tool_id = format!( - "{}-{}", - &function.name, - TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed) - ); - let event = - LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { - id: LanguageModelToolUseId::from(tool_id), - name: Arc::from(function.name), - raw_input: function.arguments.to_string(), - input: function.arguments, - is_input_complete: true, - }); - events.push(Ok(event)); - state.used_tools = true; - } - } + let OllamaToolCall { id, function } = tool_call; + let id = id.unwrap_or_else(|| { + format!( + "{}-{}", + &function.name, + TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed) + ) + }); + let event = LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: LanguageModelToolUseId::from(id), + name: Arc::from(function.name), + raw_input: function.arguments.to_string(), + input: function.arguments, + is_input_complete: true, + }); + events.push(Ok(event)); + state.used_tools = true; } else if !content.is_empty() { events.push(Ok(LanguageModelCompletionEvent::Text(content))); } diff --git a/crates/ollama/src/ollama.rs b/crates/ollama/src/ollama.rs index 0ed3b6da17d952cc874485337ec380ef3ca990a8..f6614379fa999883405a20d17328c61d7da448f2 100644 --- a/crates/ollama/src/ollama.rs +++ b/crates/ollama/src/ollama.rs @@ -102,9 +102,11 @@ pub enum ChatMessage { } #[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all = "lowercase")] -pub enum OllamaToolCall { - Function(OllamaFunctionCall), +pub struct OllamaToolCall { + // TODO: Remove `Option` after most users have updated to Ollama v0.12.10, + // which was released on the 4th of November 2025 + pub id: Option, + pub function: OllamaFunctionCall, } #[derive(Serialize, Deserialize, Debug)] @@ -444,6 +446,7 @@ mod tests { "content": "", "tool_calls": [ { + "id": "call_llama3.2:3b_145155", "function": { "name": "weather", "arguments": { @@ -479,6 +482,56 @@ mod tests { } } + // Backwards compatibility with Ollama versions prior to v0.12.10 November 2025 + // This test is a copy of `parse_tool_call()` with the `id` field omitted. + #[test] + fn parse_tool_call_pre_0_12_10() { + let response = serde_json::json!({ + "model": "llama3.2:3b", + "created_at": "2025-04-28T20:02:02.140489Z", + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "function": { + "name": "weather", + "arguments": { + "city": "london", + } + } + } + ] + }, + "done_reason": "stop", + "done": true, + "total_duration": 2758629166u64, + "load_duration": 1770059875, + "prompt_eval_count": 147, + "prompt_eval_duration": 684637583, + "eval_count": 16, + "eval_duration": 302561917, + }); + + let result: ChatResponseDelta = serde_json::from_value(response).unwrap(); + match result.message { + ChatMessage::Assistant { + content, + tool_calls: Some(tool_calls), + images: _, + thinking, + } => { + assert!(content.is_empty()); + assert!(thinking.is_none()); + + // When the `Option` around `id` is removed, this test should complain + // and be subsequently deleted in favor of `parse_tool_call()` + assert!(tool_calls.first().is_some_and(|call| call.id.is_none())) + } + _ => panic!("Deserialized wrong role"), + } + } + #[test] fn parse_show_model() { let response = serde_json::json!({