From 502f7d5ddb4488da58097cf6b20c09bfe873cad5 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Tue, 29 Sep 2026 10:59:15 +0530 Subject: [PATCH 1/2] Python: fix(ollama): send the tool name on tool result messages Function results don't carry the tool name, so every tool message was sent to Ollama with tool_name=''. Look the name up from the function call with the same call_id. --- .../agent_framework_ollama/_chat_client.py | 20 +++++++++++--- .../ollama/tests/test_ollama_chat_client.py | 26 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index 60c6a314b97..f114f10a9c1 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -459,7 +459,18 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An return run_options def _prepare_messages_for_ollama(self, messages: Sequence[Message]) -> list[OllamaMessage]: - ollama_messages = [self._prepare_message_for_ollama(msg) for msg in messages] + # Function results don't carry the tool name, but Ollama expects it on tool messages, + # so look it up from the function call with the same call_id. + tool_names = { + content.call_id: content.name + for message in messages + for content in message.contents + if content.type == "function_call" and content.call_id and content.name + } + ollama_messages = [ + self._format_tool_message(msg, tool_names) if msg.role == "tool" else self._prepare_message_for_ollama(msg) + for msg in messages + ] # Flatten the list of lists into a single list return list(chain.from_iterable(ollama_messages)) @@ -518,7 +529,9 @@ def _format_assistant_message(self, message: Message) -> list[OllamaMessage]: ] return [assistant_message] - def _format_tool_message(self, message: Message) -> list[OllamaMessage]: + def _format_tool_message( + self, message: Message, tool_names: Mapping[str, str] | None = None + ) -> list[OllamaMessage]: # Ollama does not support multiple tool results in a single message, so we create a separate messages: list[OllamaMessage] = [] for item in message.contents: @@ -535,8 +548,7 @@ def _format_tool_message(self, message: Message) -> list[OllamaMessage]: else: tool_text = str(item.result) if item.result is not None else "" - # Get the tool name directly from the content item. - tool_name = getattr(item, "name", "") or "" + tool_name = getattr(item, "name", None) or (tool_names or {}).get(item.call_id or "", "") messages.append(OllamaMessage(role="tool", content=tool_text, tool_name=tool_name)) return messages diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index 9f00c73b608..5e9be929e06 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -829,6 +829,32 @@ def test_mixed_policy_approval_roles_preserve_tool_result(self) -> None: assert [message.role for message in prepared] == ["tool", "assistant"] assert prepared[0].content == "safe result" + def test_tool_message_gets_name_from_matching_function_call(self) -> None: + """Function results carry no name, so the tool name comes from the call with the same call_id.""" + client = OllamaChatClient(host="http://localhost:12345", model="test-model") + messages = [ + Message(role="user", contents=[Content.from_text("weather in Paris and Oslo?")]), + Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="c1", name="get_weather", arguments={"city": "Paris"}), + Content.from_function_call(call_id="c2", name="get_time", arguments={"city": "Oslo"}), + ], + ), + Message( + role="tool", + contents=[ + Content.from_function_result(call_id="c2", result="10:00"), + Content.from_function_result(call_id="c1", result="sunny"), + ], + ), + ] + + prepared = client._prepare_messages_for_ollama(messages) + + tool_messages = [message for message in prepared if message.role == "tool"] + assert [(m.content, m.tool_name) for m in tool_messages] == [("10:00", "get_time"), ("sunny", "get_weather")] + def test_prepare_options_single_stop_string_becomes_list(ollama_unit_test_env: dict[str, str]) -> None: """Ollama expects options.stop to be a list, so a single stop string is wrapped.""" From d337592a99fe1217c86b4b618da625c861b0c90c Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Tue, 29 Sep 2026 17:43:47 +0530 Subject: [PATCH 2/2] Match each tool result to its pending call in order so reused call_ids keep the right name --- .../agent_framework_ollama/_chat_client.py | 34 +++++++++++-------- .../ollama/tests/test_ollama_chat_client.py | 17 ++++++++++ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index f114f10a9c1..767adb33b5c 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -6,6 +6,7 @@ import logging import sys import uuid +from collections import deque from collections.abc import ( AsyncIterable, Awaitable, @@ -459,18 +460,19 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An return run_options def _prepare_messages_for_ollama(self, messages: Sequence[Message]) -> list[OllamaMessage]: - # Function results don't carry the tool name, but Ollama expects it on tool messages, - # so look it up from the function call with the same call_id. - tool_names = { - content.call_id: content.name - for message in messages - for content in message.contents - if content.type == "function_call" and content.call_id and content.name - } - ollama_messages = [ - self._format_tool_message(msg, tool_names) if msg.role == "tool" else self._prepare_message_for_ollama(msg) - for msg in messages - ] + # Function results don't carry the tool name, but Ollama expects it on tool messages. + # Walk the messages in order and give each result the name of the earliest unanswered + # call with the same call_id, so a call_id reused later still maps correctly. + pending_calls: dict[str, deque[str]] = {} + ollama_messages: list[list[OllamaMessage]] = [] + for msg in messages: + if msg.role == "tool": + ollama_messages.append(self._format_tool_message(msg, pending_calls)) + continue + for content in msg.contents: + if content.type == "function_call" and content.call_id and content.name: + pending_calls.setdefault(content.call_id, deque()).append(content.name) + ollama_messages.append(self._prepare_message_for_ollama(msg)) # Flatten the list of lists into a single list return list(chain.from_iterable(ollama_messages)) @@ -530,7 +532,7 @@ def _format_assistant_message(self, message: Message) -> list[OllamaMessage]: return [assistant_message] def _format_tool_message( - self, message: Message, tool_names: Mapping[str, str] | None = None + self, message: Message, pending_calls: dict[str, deque[str]] | None = None ) -> list[OllamaMessage]: # Ollama does not support multiple tool results in a single message, so we create a separate messages: list[OllamaMessage] = [] @@ -548,7 +550,11 @@ def _format_tool_message( else: tool_text = str(item.result) if item.result is not None else "" - tool_name = getattr(item, "name", None) or (tool_names or {}).get(item.call_id or "", "") + tool_name = getattr(item, "name", None) or "" + pending = (pending_calls or {}).get(item.call_id or "") + if pending: + queued_name = pending.popleft() + tool_name = tool_name or queued_name messages.append(OllamaMessage(role="tool", content=tool_text, tool_name=tool_name)) return messages diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index 5e9be929e06..2b9e5bf9796 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -855,6 +855,23 @@ def test_tool_message_gets_name_from_matching_function_call(self) -> None: tool_messages = [message for message in prepared if message.role == "tool"] assert [(m.content, m.tool_name) for m in tool_messages] == [("10:00", "get_time"), ("sunny", "get_weather")] + def test_tool_message_name_with_reused_call_id(self) -> None: + """A call_id reused later in the transcript maps each result to its own call.""" + client = OllamaChatClient(host="http://localhost:12345", model="test-model") + messages = [ + Message(role="user", contents=[Content.from_text("weather?")]), + Message(role="assistant", contents=[Content.from_function_call(call_id="c1", name="get_weather")]), + Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="sunny")]), + Message(role="user", contents=[Content.from_text("time?")]), + Message(role="assistant", contents=[Content.from_function_call(call_id="c1", name="get_time")]), + Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="10:00")]), + ] + + prepared = client._prepare_messages_for_ollama(messages) + + tool_messages = [message for message in prepared if message.role == "tool"] + assert [(m.content, m.tool_name) for m in tool_messages] == [("sunny", "get_weather"), ("10:00", "get_time")] + def test_prepare_options_single_stop_string_becomes_list(ollama_unit_test_env: dict[str, str]) -> None: """Ollama expects options.stop to be a list, so a single stop string is wrapped."""