diff --git a/lib/crewai/src/crewai/agents/crew_agent_executor.py b/lib/crewai/src/crewai/agents/crew_agent_executor.py index c362361a8..03ee6eb2d 100644 --- a/lib/crewai/src/crewai/agents/crew_agent_executor.py +++ b/lib/crewai/src/crewai/agents/crew_agent_executor.py @@ -311,16 +311,21 @@ class CrewAgentExecutor(CrewAgentExecutorMixin): Final answer from the agent. """ # Check if model supports native function calling - use_native_tools = ( + supports_fc = ( hasattr(self.llm, "supports_function_calling") and callable(getattr(self.llm, "supports_function_calling", None)) and self.llm.supports_function_calling() - and self.original_tools ) - if use_native_tools: + if supports_fc and self.original_tools: return self._invoke_loop_native_tools() + # FC-capable LLM with no tools but with response_model: use simple + # native call path which correctly passes response_model for structured + # output instead of dropping it in the ReAct path. + if supports_fc and not self.original_tools and self.response_model: + return self._invoke_loop_native_no_tools() + # Fall back to ReAct text-based pattern return self._invoke_loop_react() @@ -1132,16 +1137,21 @@ class CrewAgentExecutor(CrewAgentExecutorMixin): Final answer from the agent. """ # Check if model supports native function calling - use_native_tools = ( + supports_fc = ( hasattr(self.llm, "supports_function_calling") and callable(getattr(self.llm, "supports_function_calling", None)) and self.llm.supports_function_calling() - and self.original_tools ) - if use_native_tools: + if supports_fc and self.original_tools: return await self._ainvoke_loop_native_tools() + # FC-capable LLM with no tools but with response_model: use simple + # native call path which correctly passes response_model for structured + # output instead of dropping it in the ReAct path. + if supports_fc and not self.original_tools and self.response_model: + return await self._ainvoke_loop_native_no_tools() + # Fall back to ReAct text-based pattern return await self._ainvoke_loop_react() diff --git a/lib/crewai/tests/agents/test_react_output_pydantic.py b/lib/crewai/tests/agents/test_react_output_pydantic.py index 27e90a23e..35d79b562 100644 --- a/lib/crewai/tests/agents/test_react_output_pydantic.py +++ b/lib/crewai/tests/agents/test_react_output_pydantic.py @@ -13,7 +13,6 @@ conversion to pydantic/json should happen in task._export_output() after the ReA from __future__ import annotations -import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -180,6 +179,52 @@ class TestReActFlowDoesNotPassResponseModel: mock_native.assert_called_once() mock_react.assert_not_called() + def test_invoke_loop_routes_to_native_no_tools_when_fc_no_tools_with_response_model( + self, + ) -> None: + """When LLM supports FC, has no tools, but HAS a response_model, + route to _invoke_loop_native_no_tools (which correctly passes + response_model) instead of falling through to the ReAct path.""" + llm = _make_llm(supports_fc=True) + executor = _make_executor(llm, response_model=PersonInfo) + # No tools + executor.original_tools = [] + + with patch.object( + executor, + "_invoke_loop_native_no_tools", + return_value=AgentFinish(thought="done", output="test", text="Final Answer: test"), + ) as mock_native_no_tools: + with patch.object(executor, "_invoke_loop_react") as mock_react: + with patch.object(executor, "_invoke_loop_native_tools") as mock_native: + executor._invoke_loop() + + mock_native_no_tools.assert_called_once() + mock_react.assert_not_called() + mock_native.assert_not_called() + + def test_invoke_loop_routes_to_react_when_fc_no_tools_no_response_model( + self, + ) -> None: + """When LLM supports FC, has no tools, and NO response_model, + fall through to ReAct path (no structured output to preserve).""" + llm = _make_llm(supports_fc=True) + executor = _make_executor(llm, response_model=None) + executor.original_tools = [] + + with patch.object( + executor, + "_invoke_loop_react", + return_value=AgentFinish(thought="done", output="test", text="Final Answer: test"), + ) as mock_react: + with patch.object(executor, "_invoke_loop_native_no_tools") as mock_native_no_tools: + with patch.object(executor, "_invoke_loop_native_tools") as mock_native: + executor._invoke_loop() + + mock_react.assert_called_once() + mock_native_no_tools.assert_not_called() + mock_native.assert_not_called() + def test_react_flow_still_works_with_tool_usage(self) -> None: """Verify the ReAct loop still processes Action/Observation cycles correctly even when output_pydantic is set."""