diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index ea652aff941..4bda11a11a9 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -32,6 +32,7 @@ Message, ResponseStream, UsageDetails, + validate_tool_mode, ) from agent_framework._settings import load_settings from agent_framework._telemetry import mark_feature_used @@ -97,7 +98,8 @@ class OllamaChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to (converted to its JSON schema) for structured output. # Options not supported in Ollama: - tool_choice: Ollama only supports auto tool choice. + tool_choice: Only ``auto`` and ``none`` are supported (``none`` omits the tools). + ``required`` and ``allowed_tools`` raise ``ChatClientInvalidRequestException``. allow_multiple_tool_calls: Not configurable. user: Not supported. store: Not supported. @@ -216,9 +218,6 @@ class OllamaChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to """For thinking models: whether the model should think before responding.""" # ChatOptions fields not supported in Ollama - tool_choice: None # type: ignore[misc] - """Not supported. Ollama only supports auto tool choice.""" - allow_multiple_tool_calls: None # type: ignore[misc] """Not supported. Not configurable in Ollama.""" @@ -414,7 +413,7 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An messages = prepend_instructions_to_messages(list(messages), instructions, role="system") # Keys to exclude from processing - exclude_keys = {"instructions", "tool_choice"} + exclude_keys = {"instructions", "tool_choice", "tools"} # Build run_options and model_options separately run_options: dict[str, Any] = {} @@ -460,8 +459,21 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An run_options["model"] = self.model # tools + # Ollama has no tool_choice parameter. Only "auto" (send the tools) and "none" (don't send them) + # can be honored, so anything else is rejected instead of silently behaving like "auto". + tool_mode = validate_tool_mode(options.get("tool_choice")) + if tool_mode is not None: + if tool_mode.get("mode") == "required": + raise ChatClientInvalidRequestException( + "Ollama does not support tool_choice mode 'required'. Use 'auto' or 'none'." + ) + if "allowed_tools" in tool_mode: + raise ChatClientInvalidRequestException( + "Ollama does not support tool_choice 'allowed_tools'. Pass only the tools you want to allow." + ) tools = options.get("tools") - if tools is not None and (prepared_tools := self._prepare_tools_for_ollama(tools)): + tools_disabled = tool_mode is not None and tool_mode.get("mode") == "none" + if tools is not None and not tools_disabled and (prepared_tools := self._prepare_tools_for_ollama(tools)): run_options["tools"] = prepared_tools return run_options diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index be5c62218a3..90c66b18794 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -17,7 +17,12 @@ chat_middleware, tool, ) -from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException, SettingNotFoundError +from agent_framework.exceptions import ( + ChatClientException, + ChatClientInvalidRequestException, + ContentError, + SettingNotFoundError, +) from ollama import AsyncClient from ollama._types import ChatResponse as OllamaChatResponse from ollama._types import Message as OllamaMessage @@ -910,6 +915,80 @@ def test_prepare_options_single_stop_string_becomes_list(ollama_unit_test_env: d assert request["options"]["stop"] == ["END"] +@pytest.mark.parametrize("tool_choice", ["none", {"mode": "none"}]) +def test_prepare_options_tool_choice_none_omits_tools(ollama_unit_test_env: dict[str, str], tool_choice: Any) -> None: + """Ollama has no tool_choice parameter, so "none" is honored by not offering the tools.""" + client = OllamaChatClient() + messages = [Message(role="user", contents=[Content.from_text(text="hello")])] + + request = client._prepare_options(messages, {"tools": [hello_world], "tool_choice": tool_choice}) + + assert "tools" not in request + assert "tool_choice" not in request + + +@pytest.mark.parametrize("tool_choice", [None, "auto", {"mode": "auto"}]) +def test_prepare_options_auto_tool_choice_keeps_tools(ollama_unit_test_env: dict[str, str], tool_choice: Any) -> None: + """No tool_choice or "auto" sends the tools to Ollama.""" + client = OllamaChatClient() + messages = [Message(role="user", contents=[Content.from_text(text="hello")])] + + request = client._prepare_options(messages, {"tools": [hello_world], "tool_choice": tool_choice}) + + assert request["tools"] == [hello_world.to_json_schema_spec()] + assert "tool_choice" not in request + + +@patch.object(AsyncClient, "chat", new_callable=AsyncMock) +async def test_cmc_function_invocation_limit_final_request_omits_tools( + mock_chat: AsyncMock, + ollama_unit_test_env: dict[str, str], + chat_history: list[Message], + mock_chat_completion_tool_call: OllamaChatResponse, + mock_chat_completion_response: OllamaChatResponse, +) -> None: + """After the function invocation limit, the final request must not offer tools to Ollama.""" + mock_chat.side_effect = [mock_chat_completion_tool_call, mock_chat_completion_response] + chat_history.append(Message(contents=["hello world"], role="user")) + + ollama_client = OllamaChatClient() + ollama_client.function_invocation_configuration["max_iterations"] = 1 + result = await ollama_client.get_response(messages=chat_history, options={"tools": [hello_world]}) + + assert mock_chat.call_count == 2 + assert "tools" in mock_chat.call_args_list[0].kwargs + assert "tools" not in mock_chat.call_args_list[1].kwargs + assert result.text == "test" + + +def test_prepare_options_invalid_tool_choice_raises(ollama_unit_test_env: dict[str, str]) -> None: + """An invalid tool_choice is rejected instead of being silently ignored.""" + client = OllamaChatClient() + messages = [Message(role="user", contents=[Content.from_text(text="hello")])] + + with pytest.raises(ContentError): + client._prepare_options(messages, {"tools": [hello_world], "tool_choice": "bogus"}) + + +@pytest.mark.parametrize( + "tool_choice", + [ + "required", + {"mode": "required"}, + {"mode": "required", "required_function_name": "hello_world"}, + {"mode": "auto", "allowed_tools": ["hello_world"]}, + {"mode": "required", "allowed_tools": ["hello_world"]}, + ], +) +def test_prepare_options_unsupported_tool_choice_raises(ollama_unit_test_env: dict[str, str], tool_choice: Any) -> None: + """Ollama can't enforce "required" or "allowed_tools", so they raise instead of acting like "auto".""" + client = OllamaChatClient() + messages = [Message(role="user", contents=[Content.from_text(text="hello")])] + + with pytest.raises(ChatClientInvalidRequestException): + client._prepare_options(messages, {"tools": [hello_world], "tool_choice": tool_choice}) + + async def test_cmc_streaming_closes_sdk_stream_when_consumer_stops_early(ollama_unit_test_env: dict[str, str]) -> None: """Stopping early must close the Ollama SDK stream instead of leaving it to garbage collection.""" closed = False