diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index 60c6a314b97..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,7 +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]: - 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. + # 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)) @@ -518,7 +531,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, 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] = [] for item in message.contents: @@ -535,8 +550,11 @@ 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 "" + 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 9f00c73b608..2b9e5bf9796 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -829,6 +829,49 @@ 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_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."""