Compare commits

..

1 Commits

Author SHA1 Message Date
Devin AI
d0e629ee8d Fix task configuration with None context parameter in YAML
Fixes #3696

When context: None is specified in YAML, yaml.safe_load() converts it to
the string 'None' instead of Python's None object. The code was attempting
to iterate over this string character by character, causing KeyError: 'N'
when trying to look up task names.

Changes:
- Added isinstance(context_list, list) check in crew_base.py before
  iterating context_list to handle YAML's conversion of None to string
- Added test case test_task_with_none_context_from_yaml to verify tasks
  can be configured with context: None without errors
- Added test YAML configurations in tests/config_none_context/ to
  reproduce and verify the fix

The fix ensures that only actual list values are processed, allowing
None and other non-list values to pass through without causing errors.

Co-Authored-By: João <joao@crewai.com>
2025-10-11 06:34:20 +00:00
17 changed files with 119 additions and 170 deletions

View File

@@ -40,7 +40,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "0.203.1"
__version__ = "0.203.0"
_telemetry_submitted = False

View File

@@ -30,7 +30,6 @@ def validate_jwt_token(
algorithms=["RS256"],
audience=audience,
issuer=issuer,
leeway=10.0,
options={
"verify_signature": True,
"verify_exp": True,

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]>=0.203.1,<1.0.0"
"crewai[tools]>=0.203.0,<1.0.0"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]>=0.203.1,<1.0.0",
"crewai[tools]>=0.203.0,<1.0.0",
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]>=0.203.1"
"crewai[tools]>=0.203.0"
]
[tool.crewai]

View File

@@ -358,8 +358,7 @@ def prompt_user_for_trace_viewing(timeout_seconds: int = 20) -> bool:
try:
response = input().strip().lower()
result[0] = response in ["y", "yes"]
except (EOFError, KeyboardInterrupt, OSError, LookupError):
# Handle all input-related errors silently
except (EOFError, KeyboardInterrupt):
result[0] = False
input_thread = threading.Thread(target=get_input, daemon=True)
@@ -372,7 +371,6 @@ def prompt_user_for_trace_viewing(timeout_seconds: int = 20) -> bool:
return result[0]
except Exception:
# Suppress any warnings or errors and assume "no"
return False

View File

@@ -299,7 +299,6 @@ class LLM(BaseLLM):
callbacks: list[Any] | None = None,
reasoning_effort: Literal["none", "low", "medium", "high"] | None = None,
stream: bool = False,
supports_function_calling: bool | None = None,
**kwargs,
):
self.model = model
@@ -326,7 +325,6 @@ class LLM(BaseLLM):
self.additional_params = kwargs
self.is_anthropic = self._is_anthropic_model(model)
self.stream = stream
self._supports_function_calling_override = supports_function_calling
litellm.drop_params = True
@@ -1199,9 +1197,6 @@ class LLM(BaseLLM):
)
def supports_function_calling(self) -> bool:
if self._supports_function_calling_override is not None:
return self._supports_function_calling_override
try:
provider = self._get_custom_llm_provider()
return litellm.utils.supports_function_calling(

View File

@@ -9,7 +9,6 @@ from typing import Any, Final
DEFAULT_CONTEXT_WINDOW_SIZE: Final[int] = 4096
DEFAULT_SUPPORTS_STOP_WORDS: Final[bool] = True
DEFAULT_SUPPORTS_FUNCTION_CALLING: Final[bool] = True
class BaseLLM(ABC):
@@ -83,14 +82,6 @@ class BaseLLM(ABC):
RuntimeError: If the LLM request fails for other reasons.
"""
def supports_function_calling(self) -> bool:
"""Check if the LLM supports function calling.
Returns:
True if the LLM supports function calling, False otherwise.
"""
return DEFAULT_SUPPORTS_FUNCTION_CALLING
def supports_stop_words(self) -> bool:
"""Check if the LLM supports stop words.

View File

@@ -260,9 +260,10 @@ def CrewBase(cls: T) -> T: # noqa: N802
output_pydantic_functions: dict[str, Callable],
) -> None:
if context_list := task_info.get("context"):
self.tasks_config[task_name]["context"] = [
tasks[context_task_name]() for context_task_name in context_list
]
if isinstance(context_list, list):
self.tasks_config[task_name]["context"] = [
tasks[context_task_name]() for context_task_name in context_list
]
if tools := task_info.get("tools"):
self.tasks_config[task_name]["tools"] = [

View File

@@ -7,7 +7,7 @@ import uuid
import warnings
from collections.abc import Callable
from concurrent.futures import Future
from copy import copy as shallow_copy
from copy import copy
from hashlib import md5
from pathlib import Path
from typing import (
@@ -672,9 +672,7 @@ Follow these guidelines:
copied_data = {k: v for k, v in copied_data.items() if v is not None}
cloned_context = (
self.context
if self.context is NOT_SPECIFIED
else [task_mapping[context_task.key] for context_task in self.context]
[task_mapping[context_task.key] for context_task in self.context]
if isinstance(self.context, list)
else None
)
@@ -683,7 +681,7 @@ Follow these guidelines:
return next((agent for agent in agents if agent.role == role), None)
cloned_agent = get_agent_by_role(self.agent.role) if self.agent else None
cloned_tools = shallow_copy(self.tools) if self.tools else []
cloned_tools = copy(self.tools) if self.tools else []
return self.__class__(
**copied_data,

View File

@@ -1,7 +1,7 @@
import jwt
import unittest
from unittest.mock import MagicMock, patch
import jwt
from crewai.cli.authentication.utils import validate_jwt_token
@@ -17,22 +17,19 @@ class TestUtils(unittest.TestCase):
key="mock_signing_key"
)
jwt_token = "aaaaa.bbbbbb.cccccc" # noqa: S105
decoded_token = validate_jwt_token(
jwt_token=jwt_token,
jwt_token="aaaaa.bbbbbb.cccccc",
jwks_url="https://mock_jwks_url",
issuer="https://mock_issuer",
audience="app_id_xxxx",
)
mock_jwt.decode.assert_called_with(
jwt_token,
"aaaaa.bbbbbb.cccccc",
"mock_signing_key",
algorithms=["RS256"],
audience="app_id_xxxx",
issuer="https://mock_issuer",
leeway=10.0,
options={
"verify_signature": True,
"verify_exp": True,
@@ -46,9 +43,9 @@ class TestUtils(unittest.TestCase):
def test_validate_jwt_token_expired(self, mock_jwt, mock_pyjwkclient):
mock_jwt.decode.side_effect = jwt.ExpiredSignatureError
with self.assertRaises(Exception): # noqa: B017
with self.assertRaises(Exception):
validate_jwt_token(
jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106
jwt_token="aaaaa.bbbbbb.cccccc",
jwks_url="https://mock_jwks_url",
issuer="https://mock_issuer",
audience="app_id_xxxx",
@@ -56,9 +53,9 @@ class TestUtils(unittest.TestCase):
def test_validate_jwt_token_invalid_audience(self, mock_jwt, mock_pyjwkclient):
mock_jwt.decode.side_effect = jwt.InvalidAudienceError
with self.assertRaises(Exception): # noqa: B017
with self.assertRaises(Exception):
validate_jwt_token(
jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106
jwt_token="aaaaa.bbbbbb.cccccc",
jwks_url="https://mock_jwks_url",
issuer="https://mock_issuer",
audience="app_id_xxxx",
@@ -66,9 +63,9 @@ class TestUtils(unittest.TestCase):
def test_validate_jwt_token_invalid_issuer(self, mock_jwt, mock_pyjwkclient):
mock_jwt.decode.side_effect = jwt.InvalidIssuerError
with self.assertRaises(Exception): # noqa: B017
with self.assertRaises(Exception):
validate_jwt_token(
jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106
jwt_token="aaaaa.bbbbbb.cccccc",
jwks_url="https://mock_jwks_url",
issuer="https://mock_issuer",
audience="app_id_xxxx",
@@ -78,9 +75,9 @@ class TestUtils(unittest.TestCase):
self, mock_jwt, mock_pyjwkclient
):
mock_jwt.decode.side_effect = jwt.MissingRequiredClaimError
with self.assertRaises(Exception): # noqa: B017
with self.assertRaises(Exception):
validate_jwt_token(
jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106
jwt_token="aaaaa.bbbbbb.cccccc",
jwks_url="https://mock_jwks_url",
issuer="https://mock_issuer",
audience="app_id_xxxx",
@@ -88,9 +85,9 @@ class TestUtils(unittest.TestCase):
def test_validate_jwt_token_jwks_error(self, mock_jwt, mock_pyjwkclient):
mock_jwt.decode.side_effect = jwt.exceptions.PyJWKClientError
with self.assertRaises(Exception): # noqa: B017
with self.assertRaises(Exception):
validate_jwt_token(
jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106
jwt_token="aaaaa.bbbbbb.cccccc",
jwks_url="https://mock_jwks_url",
issuer="https://mock_issuer",
audience="app_id_xxxx",
@@ -98,9 +95,9 @@ class TestUtils(unittest.TestCase):
def test_validate_jwt_token_invalid_token(self, mock_jwt, mock_pyjwkclient):
mock_jwt.decode.side_effect = jwt.InvalidTokenError
with self.assertRaises(Exception): # noqa: B017
with self.assertRaises(Exception):
validate_jwt_token(
jwt_token="aaaaa.bbbbbb.cccccc", # noqa: S106
jwt_token="aaaaa.bbbbbb.cccccc",
jwks_url="https://mock_jwks_url",
issuer="https://mock_issuer",
audience="app_id_xxxx",

View File

@@ -0,0 +1,5 @@
test_agent:
role: Test Agent
goal: Test goal
backstory: Test backstory
verbose: true

View File

@@ -0,0 +1,11 @@
task_with_none_context:
description: A test task with None context
expected_output: Some output
agent: test_agent
context: None
task_with_valid_context:
description: A test task with valid context
expected_output: Some output
agent: test_agent
context: [task_with_none_context]

View File

@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Dict, List, Optional, Union
import pytest
@@ -159,11 +159,11 @@ class JWTAuthLLM(BaseLLM):
def call(
self,
messages: str | list[dict[str, str]],
tools: list[dict] | None = None,
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
) -> str | Any:
messages: Union[str, List[Dict[str, str]]],
tools: Optional[List[dict]] = None,
callbacks: Optional[List[Any]] = None,
available_functions: Optional[Dict[str, Any]] = None,
) -> Union[str, Any]:
"""Record the call and return a predefined response."""
self.calls.append(
{
@@ -192,7 +192,7 @@ class JWTAuthLLM(BaseLLM):
def test_custom_llm_with_jwt_auth():
"""Test a custom LLM implementation with JWT authentication."""
jwt_llm = JWTAuthLLM(jwt_token="example.jwt.token") # noqa: S106
jwt_llm = JWTAuthLLM(jwt_token="example.jwt.token")
# Test that create_llm returns the JWT-authenticated LLM instance directly
result_llm = create_llm(jwt_llm)
@@ -238,11 +238,11 @@ class TimeoutHandlingLLM(BaseLLM):
def call(
self,
messages: str | list[dict[str, str]],
tools: list[dict] | None = None,
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
) -> str | Any:
messages: Union[str, List[Dict[str, str]]],
tools: Optional[List[dict]] = None,
callbacks: Optional[List[Any]] = None,
available_functions: Optional[Dict[str, Any]] = None,
) -> Union[str, Any]:
"""Simulate API calls with timeout handling and retry logic.
Args:
@@ -282,34 +282,35 @@ class TimeoutHandlingLLM(BaseLLM):
)
# Otherwise, continue to the next attempt (simulating backoff)
continue
# Success on first attempt
return "First attempt response"
# This is a retry attempt (attempt > 0)
# Always record retry attempts
self.calls.append(
{
"retry_attempt": attempt,
"messages": messages,
"tools": tools,
"callbacks": callbacks,
"available_functions": available_functions,
}
)
else:
# Success on first attempt
return "First attempt response"
else:
# This is a retry attempt (attempt > 0)
# Always record retry attempts
self.calls.append(
{
"retry_attempt": attempt,
"messages": messages,
"tools": tools,
"callbacks": callbacks,
"available_functions": available_functions,
}
)
# Simulate a failure if fail_count > 0
if self.fail_count > 0:
self.fail_count -= 1
# If we've used all retries, raise an error
if attempt == self.max_retries - 1:
raise TimeoutError(
f"LLM request failed after {self.max_retries} attempts"
)
# Otherwise, continue to the next attempt (simulating backoff)
continue
# Success on retry
return "Response after retry"
return "Response after retry"
# Simulate a failure if fail_count > 0
if self.fail_count > 0:
self.fail_count -= 1
# If we've used all retries, raise an error
if attempt == self.max_retries - 1:
raise TimeoutError(
f"LLM request failed after {self.max_retries} attempts"
)
# Otherwise, continue to the next attempt (simulating backoff)
continue
else:
# Success on retry
return "Response after retry"
def supports_function_calling(self) -> bool:
"""Return True to indicate that function calling is supported.
@@ -357,25 +358,3 @@ def test_timeout_handling_llm():
with pytest.raises(TimeoutError, match="LLM request failed after 2 attempts"):
llm.call("Test message")
assert len(llm.calls) == 2 # Initial call + failed retry attempt
class MinimalCustomLLM(BaseLLM):
"""Minimal custom LLM implementation that doesn't override supports_function_calling."""
def __init__(self):
super().__init__(model="minimal-model")
def call(
self,
messages: str | list[dict[str, str]],
tools: list[dict] | None = None,
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
) -> str | Any:
return "Minimal response"
def test_base_llm_supports_function_calling_default():
"""Test that BaseLLM supports function calling by default."""
llm = MinimalCustomLLM()
assert llm.supports_function_calling() is True

View File

@@ -711,18 +711,3 @@ def test_ollama_does_not_modify_when_last_is_user(ollama_llm):
formatted = ollama_llm._format_messages_for_provider(original_messages)
assert formatted == original_messages
def test_supports_function_calling_with_override_true():
llm = LLM(model="custom-model/my-model", supports_function_calling=True)
assert llm.supports_function_calling() is True
def test_supports_function_calling_with_override_false():
llm = LLM(model="gpt-4o-mini", supports_function_calling=False)
assert llm.supports_function_calling() is False
def test_supports_function_calling_without_override():
llm = LLM(model="gpt-4o-mini")
assert llm.supports_function_calling() is True

View File

@@ -287,3 +287,38 @@ def test_internal_crew_with_mcp():
adapter_mock.assert_called_once_with(
{"host": "localhost", "port": 8000}, connect_timeout=120
)
def test_task_with_none_context_from_yaml():
@CrewBase
class CrewWithNoneContext:
agents_config = "config_none_context/agents.yaml"
tasks_config = "config_none_context/tasks.yaml"
agents: list[BaseAgent]
tasks: list[Task]
@agent
def test_agent(self):
return Agent(config=self.agents_config["test_agent"])
@task
def task_with_none_context(self):
return Task(config=self.tasks_config["task_with_none_context"])
@task
def task_with_valid_context(self):
return Task(config=self.tasks_config["task_with_valid_context"])
@crew
def crew(self):
return Crew(agents=self.agents, tasks=self.tasks, verbose=True)
crew_instance = CrewWithNoneContext()
task_none = crew_instance.task_with_none_context()
assert task_none is not None
task_valid = crew_instance.task_with_valid_context()
assert task_valid.context is not None
assert len(task_valid.context) == 1

View File

@@ -1218,7 +1218,7 @@ def test_create_directory_false():
assert not resolved_dir.exists()
with pytest.raises(
RuntimeError, match=r"Directory .* does not exist and create_directory is False"
RuntimeError, match="Directory .* does not exist and create_directory is False"
):
task._save_file("test content")
@@ -1635,48 +1635,3 @@ def test_task_interpolation_with_hyphens():
assert "say hello world" in task.prompt()
assert result.raw == "Hello, World!"
def test_task_copy_with_none_context():
original_task = Task(
description="Test task",
expected_output="Test output",
context=None
)
new_task = original_task.copy(agents=[], task_mapping={})
assert original_task.context is None
assert new_task.context is None
def test_task_copy_with_not_specified_context():
from crewai.utilities.constants import NOT_SPECIFIED
original_task = Task(
description="Test task",
expected_output="Test output",
)
new_task = original_task.copy(agents=[], task_mapping={})
assert original_task.context is NOT_SPECIFIED
assert new_task.context is NOT_SPECIFIED
def test_task_copy_with_list_context():
"""Test that copying a task with list context works correctly."""
task1 = Task(
description="Task 1",
expected_output="Output 1"
)
task2 = Task(
description="Task 2",
expected_output="Output 2",
context=[task1]
)
task_mapping = {task1.key: task1}
copied_task2 = task2.copy(agents=[], task_mapping=task_mapping)
assert isinstance(copied_task2.context, list)
assert len(copied_task2.context) == 1
assert copied_task2.context[0] is task1