Compare commits

..

1 Commits

Author SHA1 Message Date
Devin AI
e0ee53b1b6 Fix: Load .env before importing crew module in chat command
This fixes issue #3934 where 'crewai chat' fails when the crew has
module-level LLM instantiation that requires OPENAI_API_KEY.

The issue occurred because 'crewai chat' imports the crew module directly
using __import__(), and any module-level code executes immediately during
import. At this point, the .env file hadn't been loaded yet, so
OPENAI_API_KEY was not available in the environment.

Changes:
- Load .env file in load_crew_and_name() before importing the crew module
- Use os.environ.setdefault() to avoid overriding existing env vars
- Add comprehensive tests covering:
  - Loading crew with .env containing OPENAI_API_KEY
  - Environment variable precedence (existing vars not overridden)
  - Loading crew without .env file

Fixes #3934

Co-Authored-By: João <joao@crewai.com>
2025-11-17 22:18:23 +00:00
6 changed files with 220 additions and 202 deletions

View File

@@ -1,4 +1,5 @@
import json
import os
from pathlib import Path
import platform
import re
@@ -11,7 +12,7 @@ import click
from packaging import version
import tomli
from crewai.cli.utils import read_toml
from crewai.cli.utils import load_env_vars, read_toml
from crewai.cli.version import get_crewai_version
from crewai.crew import Crew
from crewai.llm import LLM, BaseLLM
@@ -328,6 +329,11 @@ def load_crew_and_name() -> tuple[Crew, str]:
# Get the current working directory
cwd = Path.cwd()
# Load environment variables from .env file before importing the crew module
env_vars = load_env_vars(cwd)
for key, value in env_vars.items():
os.environ.setdefault(key, value)
# Path to the pyproject.toml file
pyproject_path = cwd / "pyproject.toml"
if not pyproject_path.exists():

View File

@@ -66,6 +66,7 @@ class SSETransport(BaseTransport):
self._transport_context = sse_client(
self.url,
headers=self.headers if self.headers else None,
terminate_on_close=True,
)
read, write = await self._transport_context.__aenter__()

View File

@@ -0,0 +1,212 @@
"""Tests for crew_chat.py environment variable loading."""
import os
from unittest.mock import Mock, patch
import pytest
from crewai.cli.crew_chat import load_crew_and_name
@pytest.fixture
def temp_crew_project(tmp_path):
"""Create a temporary crew project with .env file."""
project_dir = tmp_path / "test_crew"
project_dir.mkdir()
src_dir = project_dir / "src" / "test_crew"
src_dir.mkdir(parents=True)
env_file = project_dir / ".env"
env_file.write_text("OPENAI_API_KEY=test-api-key-from-env\nMODEL=gpt-4\n")
pyproject = project_dir / "pyproject.toml"
pyproject.write_text("""[project]
name = "test_crew"
version = "0.1.0"
description = "Test crew"
requires-python = ">=3.10"
dependencies = ["crewai"]
[tool.crewai]
type = "crew"
""")
(src_dir / "__init__.py").write_text("")
crew_py = src_dir / "crew.py"
crew_py.write_text("""from crewai import Agent, Crew, Process, Task, LLM
from crewai.project import CrewBase, agent, crew, task
default_llm = LLM(model="openai/gpt-4")
@CrewBase
class TestCrew:
'''Test crew'''
@agent
def researcher(self) -> Agent:
return Agent(
role="Researcher",
goal="Research topics",
backstory="You are a researcher",
llm=default_llm,
)
@task
def research_task(self) -> Task:
return Task(
description="Research {topic}",
expected_output="A report",
agent=self.researcher(),
)
@crew
def crew(self) -> Crew:
return Crew(
agents=[self.researcher()],
tasks=[self.research_task()],
process=Process.sequential,
verbose=True,
)
""")
config_dir = src_dir / "config"
config_dir.mkdir()
agents_yaml = config_dir / "agents.yaml"
agents_yaml.write_text("""researcher:
role: Researcher
goal: Research topics
backstory: You are a researcher
""")
tasks_yaml = config_dir / "tasks.yaml"
tasks_yaml.write_text("""research_task:
description: Research {topic}
expected_output: A report
agent: researcher
""")
return project_dir
def test_load_crew_with_env_file(temp_crew_project, monkeypatch):
"""Test that load_crew_and_name loads .env before importing crew module."""
monkeypatch.chdir(temp_crew_project)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
with patch("crewai.llm.LLM") as mock_llm:
mock_llm.return_value = Mock()
crew_instance, crew_name = load_crew_and_name()
assert crew_instance is not None
assert crew_name == "TestCrew"
assert os.environ.get("OPENAI_API_KEY") == "test-api-key-from-env"
assert os.environ.get("MODEL") == "gpt-4"
def test_env_var_precedence(temp_crew_project, monkeypatch):
"""Test that existing environment variables are not overridden by .env."""
monkeypatch.chdir(temp_crew_project)
existing_key = "existing-api-key-from-shell"
monkeypatch.setenv("OPENAI_API_KEY", existing_key)
with patch("crewai.llm.LLM") as mock_llm:
mock_llm.return_value = Mock()
crew_instance, crew_name = load_crew_and_name()
assert crew_instance is not None
assert crew_name == "TestCrew"
assert os.environ.get("OPENAI_API_KEY") == existing_key
assert os.environ.get("MODEL") == "gpt-4"
def test_load_crew_without_env_file(tmp_path, monkeypatch):
"""Test that load_crew_and_name works even without .env file."""
project_dir = tmp_path / "test_crew_no_env"
project_dir.mkdir()
src_dir = project_dir / "src" / "test_crew_no_env"
src_dir.mkdir(parents=True)
pyproject = project_dir / "pyproject.toml"
pyproject.write_text("""[project]
name = "test_crew_no_env"
version = "0.1.0"
description = "Test crew without env"
requires-python = ">=3.10"
dependencies = ["crewai"]
[tool.crewai]
type = "crew"
""")
(src_dir / "__init__.py").write_text("")
crew_py = src_dir / "crew.py"
crew_py.write_text("""from crewai import Agent, Crew, Process, Task
from crewai.project import CrewBase, agent, crew, task
@CrewBase
class TestCrewNoEnv:
'''Test crew without env'''
@agent
def researcher(self) -> Agent:
return Agent(
role="Researcher",
goal="Research topics",
backstory="You are a researcher",
)
@task
def research_task(self) -> Task:
return Task(
description="Research {topic}",
expected_output="A report",
agent=self.researcher(),
)
@crew
def crew(self) -> Crew:
return Crew(
agents=[self.researcher()],
tasks=[self.research_task()],
process=Process.sequential,
verbose=True,
)
""")
config_dir = src_dir / "config"
config_dir.mkdir()
agents_yaml = config_dir / "agents.yaml"
agents_yaml.write_text("""researcher:
role: Researcher
goal: Research topics
backstory: You are a researcher
""")
tasks_yaml = config_dir / "tasks.yaml"
tasks_yaml.write_text("""research_task:
description: Research {topic}
expected_output: A report
agent: researcher
""")
monkeypatch.chdir(project_dir)
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
crew_instance, crew_name = load_crew_and_name()
assert crew_instance is not None
assert crew_name == "TestCrewNoEnv"

View File

@@ -1,83 +0,0 @@
import sys
from types import ModuleType
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from crewai.agent.core import Agent
from crewai.mcp import MCPServerSSE
@pytest.fixture(autouse=True)
def isolate_storage(tmp_path, monkeypatch):
monkeypatch.setenv("CREWAI_STORAGE_DIR", str(tmp_path / "storage"))
class FakeSSEClientError:
def __init__(self, url, headers=None):
self.url = url
self.headers = headers
async def __aenter__(self):
raise Exception("SSE connection failed")
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
@pytest.fixture
def mock_mcp_sse_error():
fake_mcp = ModuleType("mcp")
fake_mcp_client = ModuleType("mcp.client")
fake_mcp_client_sse = ModuleType("mcp.client.sse")
sys.modules["mcp"] = fake_mcp
sys.modules["mcp.client"] = fake_mcp_client
sys.modules["mcp.client.sse"] = fake_mcp_client_sse
mock_sse_client = MagicMock(side_effect=FakeSSEClientError)
fake_mcp_client_sse.sse_client = mock_sse_client
yield mock_sse_client
del sys.modules["mcp.client.sse"]
del sys.modules["mcp.client"]
del sys.modules["mcp"]
def test_agent_get_native_mcp_tools_raises_runtime_error_not_unbound_local_error(
mock_mcp_sse_error,
):
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
)
mcp_config = MCPServerSSE(
url="https://example.com/sse",
headers={"Authorization": "Bearer token"},
)
with pytest.raises(RuntimeError, match="Failed to get native MCP tools"):
agent._get_native_mcp_tools(mcp_config)
def test_agent_get_native_mcp_tools_error_message_contains_original_error(
mock_mcp_sse_error,
):
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
)
mcp_config = MCPServerSSE(
url="https://example.com/sse",
)
with pytest.raises(RuntimeError) as exc_info:
agent._get_native_mcp_tools(mcp_config)
assert "Failed to get native MCP tools" in str(exc_info.value)
assert exc_info.value.__cause__ is not None

View File

@@ -1 +0,0 @@

View File

@@ -1,117 +0,0 @@
import sys
from types import ModuleType
from unittest.mock import AsyncMock, MagicMock, call
import pytest
from crewai.mcp.transports.sse import SSETransport
@pytest.fixture(autouse=True)
def isolate_storage(tmp_path, monkeypatch):
monkeypatch.setenv("CREWAI_STORAGE_DIR", str(tmp_path / "storage"))
class FakeSSEClient:
def __init__(self, url, headers=None):
self.url = url
self.headers = headers
self._read = AsyncMock()
self._write = AsyncMock()
async def __aenter__(self):
return (self._read, self._write)
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
@pytest.fixture
def mock_mcp_sse():
fake_mcp = ModuleType("mcp")
fake_mcp_client = ModuleType("mcp.client")
fake_mcp_client_sse = ModuleType("mcp.client.sse")
sys.modules["mcp"] = fake_mcp
sys.modules["mcp.client"] = fake_mcp_client
sys.modules["mcp.client.sse"] = fake_mcp_client_sse
mock_sse_client = MagicMock(side_effect=FakeSSEClient)
fake_mcp_client_sse.sse_client = mock_sse_client
yield mock_sse_client
del sys.modules["mcp.client.sse"]
del sys.modules["mcp.client"]
del sys.modules["mcp"]
@pytest.mark.asyncio
async def test_sse_transport_connect_without_terminate_on_close(mock_mcp_sse):
transport = SSETransport(
url="https://example.com/sse",
headers={"Authorization": "Bearer token"},
)
await transport.connect()
mock_mcp_sse.assert_called_once_with(
"https://example.com/sse",
headers={"Authorization": "Bearer token"},
)
call_kwargs = mock_mcp_sse.call_args[1]
assert "terminate_on_close" not in call_kwargs
assert transport._connected is True
@pytest.mark.asyncio
async def test_sse_transport_connect_without_headers(mock_mcp_sse):
transport = SSETransport(url="https://example.com/sse")
await transport.connect()
mock_mcp_sse.assert_called_once_with(
"https://example.com/sse",
headers=None,
)
call_kwargs = mock_mcp_sse.call_args[1]
assert "terminate_on_close" not in call_kwargs
@pytest.mark.asyncio
async def test_sse_transport_connect_sets_streams(mock_mcp_sse):
transport = SSETransport(url="https://example.com/sse")
await transport.connect()
assert transport._read_stream is not None
assert transport._write_stream is not None
assert transport._connected is True
@pytest.mark.asyncio
async def test_sse_transport_context_manager(mock_mcp_sse):
async with SSETransport(url="https://example.com/sse") as transport:
assert transport._connected is True
assert transport._connected is False
@pytest.mark.asyncio
async def test_sse_transport_connect_failure_raises_connection_error(mock_mcp_sse):
mock_sse_client_error = MagicMock(
side_effect=Exception("Connection failed")
)
fake_mcp_client_sse = sys.modules["mcp.client.sse"]
fake_mcp_client_sse.sse_client = mock_sse_client_error
transport = SSETransport(url="https://example.com/sse")
with pytest.raises(ConnectionError, match="Failed to connect to SSE MCP server"):
await transport.connect()
assert transport._connected is False