From 9f132350379bd91ce53e8f69626042d091a36621 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Thu, 30 Apr 2026 22:53:01 +0800 Subject: [PATCH] fix(llm): preserve tool_calls when response also contains text --- lib/crewai/src/crewai/llm.py | 23 ++++++++--------- lib/crewai/tests/test_llm.py | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/lib/crewai/src/crewai/llm.py b/lib/crewai/src/crewai/llm.py index 57b00b408..1c03eddc9 100644 --- a/lib/crewai/src/crewai/llm.py +++ b/lib/crewai/src/crewai/llm.py @@ -1235,8 +1235,12 @@ class LLM(BaseLLM): # --- 4) Check for tool calls tool_calls = response_message.tool_calls or [] - # --- 5) If no tool calls or no available functions, return the text response directly as long as there is a text response - if (not tool_calls or not available_functions) and text_response: + # --- 5) If there are tool calls but no available functions, return the tool calls + if tool_calls and not available_functions: + return tool_calls + + # --- 6) If there are no tool calls to execute, return the text response directly + if not tool_calls and text_response: self._handle_emit_call_events( response=text_response, call_type=LLMCallType.LLM_CALL, @@ -1247,11 +1251,6 @@ class LLM(BaseLLM): ) return text_response - # --- 6) If there are tool calls but no available functions, return the tool calls - # This allows the caller (e.g., executor) to handle tool execution - if tool_calls and not available_functions: - return tool_calls - # --- 7) Handle tool calls if present (execute when available_functions provided) if tool_calls and available_functions: tool_result = self._handle_tool_call( @@ -1384,7 +1383,10 @@ class LLM(BaseLLM): tool_calls = response_message.tool_calls or [] - if (not tool_calls or not available_functions) and text_response: + if tool_calls and not available_functions: + return tool_calls + + if not tool_calls and text_response: self._handle_emit_call_events( response=text_response, call_type=LLMCallType.LLM_CALL, @@ -1395,11 +1397,6 @@ class LLM(BaseLLM): ) return text_response - # If there are tool calls but no available functions, return the tool calls - # This allows the caller (e.g., executor) to handle tool execution - if tool_calls and not available_functions: - return tool_calls - # Handle tool calls if present (execute when available_functions provided) if tool_calls and available_functions: tool_result = self._handle_tool_call( diff --git a/lib/crewai/tests/test_llm.py b/lib/crewai/tests/test_llm.py index dff40bdc6..b4002b8e9 100644 --- a/lib/crewai/tests/test_llm.py +++ b/lib/crewai/tests/test_llm.py @@ -177,6 +177,7 @@ def test_llm_passes_additional_params(): # Create mocks for response structure mock_message = MagicMock() mock_message.content = "Test response" + mock_message.tool_calls = None mock_choice = MagicMock() mock_choice.message = mock_message mock_response = MagicMock() @@ -1146,3 +1147,52 @@ async def test_usage_info_streaming_with_acall(): assert llm._token_usage["total_tokens"] > 0 assert len(result) > 0 + + +def _build_response_with_text_and_tool_calls(): + """Mimic a litellm ModelResponse that contains both content and tool_calls.""" + from litellm.types.utils import ChatCompletionMessageToolCall, Function + + response_message = MagicMock() + response_message.content = "I will search for the given query." + response_message.tool_calls = [ + ChatCompletionMessageToolCall( + id="call_123", + type="function", + function=Function(name="search", arguments='{"q": "x"}'), + ) + ] + choice = MagicMock(message=response_message) + response = MagicMock(choices=[choice], model_extra=None) + return response + + +def test_non_streaming_returns_tool_calls_when_text_also_present(): + """A response with both text and tool_calls must not drop the tool_calls + when available_functions is None (executor-managed tool execution path). + """ + llm = LLM(model="gpt-4o-mini", is_litellm=True) + response = _build_response_with_text_and_tool_calls() + + with patch("crewai.llm.litellm.completion", return_value=response): + result = llm.call("anything", available_functions=None) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].function.name == "search" + + +@pytest.mark.asyncio +async def test_non_streaming_async_returns_tool_calls_when_text_also_present(): + llm = LLM(model="openai/gpt-4o-mini", is_litellm=True, stream=False) + response = _build_response_with_text_and_tool_calls() + + async def _ret(*args, **kwargs): + return response + + with patch("crewai.llm.litellm.acompletion", side_effect=_ret): + result = await llm.acall("anything", available_functions=None) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].function.name == "search"