Compare commits

..

1 Commits

Author SHA1 Message Date
Devin AI
b58bbb7d83 Add regression tests for issue #3828: Flow with unpickleable objects in state
- Add test_flow_with_rlock_in_state to verify Flow works with threading.RLock in state
- Add test_flow_with_nested_unpickleable_objects to verify Flow works with unpickleable objects nested in containers
- These tests ensure the issue from version 1.3.0 (TypeError: cannot pickle '_thread.RLock' object) doesn't get reintroduced
- The issue was resolved in the current main branch by removing the _copy_state() method that used copy.deepcopy()
- Tests verify that flows with memory components or other resources containing locks work correctly

Co-Authored-By: João <joao@crewai.com>
2025-11-04 10:23:00 +00:00
3 changed files with 94 additions and 99 deletions

View File

@@ -92,43 +92,9 @@ def suppress_warnings():
class LLM:
"""
A wrapper class for language model interactions using litellm.
This class provides a unified interface for interacting with various language models
through litellm. It handles model configuration, context window sizing, and callback
management.
Args:
model (str): The identifier for the language model to use. Must be a valid model ID
with a provider prefix (e.g., 'openai/gpt-4'). Cannot be a numeric value without
a provider prefix.
timeout (Optional[Union[float, int]]): The timeout for API calls in seconds.
temperature (Optional[float]): Controls randomness in the model's output.
top_p (Optional[float]): Controls diversity via nucleus sampling.
n (Optional[int]): Number of completions to generate.
stop (Optional[Union[str, List[str]]]): Sequences where the model should stop generating.
max_completion_tokens (Optional[int]): Maximum number of tokens to generate.
max_tokens (Optional[int]): Alias for max_completion_tokens.
presence_penalty (Optional[float]): Penalizes repeated tokens.
frequency_penalty (Optional[float]): Penalizes frequent tokens.
logit_bias (Optional[Dict[int, float]]): Modifies likelihood of specific tokens.
response_format (Optional[Dict[str, Any]]): Specifies the format for the model's response.
seed (Optional[int]): Seed for deterministic outputs.
logprobs (Optional[bool]): Whether to return log probabilities.
top_logprobs (Optional[int]): Number of most likely tokens to return probabilities for.
base_url (Optional[str]): Base URL for API calls.
api_version (Optional[str]): API version to use.
api_key (Optional[str]): API key for authentication.
callbacks (List[Any]): List of callback functions.
**kwargs: Additional keyword arguments to pass to the model.
Raises:
ValueError: If the model ID is empty, whitespace, or a numeric value without a provider prefix.
"""
def __init__(
self,
model: Union[str, Any],
model: str,
timeout: Optional[Union[float, int]] = None,
temperature: Optional[float] = None,
top_p: Optional[float] = None,
@@ -149,16 +115,6 @@ class LLM:
callbacks: List[Any] = [],
**kwargs,
):
# Only validate model ID if it's not None and is a numeric value without a provider prefix
if model is not None and (
isinstance(model, (int, float)) or
(isinstance(model, str) and model.strip() and model.strip().isdigit())
):
raise ValueError(
f"Invalid model ID: {model}. Model ID cannot be a numeric value without a provider prefix. "
"Please specify a valid model ID with a provider prefix, e.g., 'openai/gpt-4'."
)
self.model = model
self.timeout = timeout
self.temperature = temperature
@@ -230,10 +186,7 @@ class LLM:
def supports_function_calling(self) -> bool:
try:
# Handle None model case
if self.model is None:
return False
params = get_supported_openai_params(model=str(self.model))
params = get_supported_openai_params(model=self.model)
return "response_format" in params
except Exception as e:
logging.error(f"Failed to get supported params: {str(e)}")
@@ -241,10 +194,7 @@ class LLM:
def supports_stop_words(self) -> bool:
try:
# Handle None model case
if self.model is None:
return False
params = get_supported_openai_params(model=str(self.model))
params = get_supported_openai_params(model=self.model)
return "stop" in params
except Exception as e:
logging.error(f"Failed to get supported params: {str(e)}")
@@ -258,10 +208,8 @@ class LLM:
self.context_window_size = int(
DEFAULT_CONTEXT_WINDOW_SIZE * CONTEXT_WINDOW_USAGE_RATIO
)
# Ensure model is a string before calling startswith
model_str = str(self.model) if not isinstance(self.model, str) else self.model
for key, value in LLM_CONTEXT_WINDOW_SIZES.items():
if model_str.startswith(key):
if self.model.startswith(key):
self.context_window_size = int(value * CONTEXT_WINDOW_USAGE_RATIO)
return self.context_window_size

View File

@@ -1,8 +1,10 @@
"""Test Flow creation and execution basic functionality."""
import asyncio
import threading
import pytest
from pydantic import BaseModel
from crewai.flow.flow import Flow, and_, listen, or_, router, start
@@ -322,3 +324,91 @@ def test_router_with_multiple_conditions():
# final_step should run after router_and
assert execution_order.index("log_final_step") > execution_order.index("router_and")
def test_flow_with_rlock_in_state():
"""Test that Flow can handle unpickleable objects like RLock in state.
Regression test for issue #3828: Flow should not crash when state contains
objects that cannot be deep copied (like threading.RLock).
In version 1.3.0, Flow._copy_state() used copy.deepcopy() which would fail
with "TypeError: cannot pickle '_thread.RLock' object" when state contained
threading locks (e.g., from memory components or LLM instances).
The current implementation no longer deep copies state, so this test verifies
that flows with unpickleable objects in state work correctly.
"""
execution_order = []
class StateWithRLock(BaseModel):
class Config:
arbitrary_types_allowed = True
counter: int = 0
lock: threading.RLock = None
class FlowWithRLock(Flow[StateWithRLock]):
@start()
def step_1(self):
execution_order.append("step_1")
self.state.counter += 1
@listen(step_1)
def step_2(self):
execution_order.append("step_2")
self.state.counter += 1
flow = FlowWithRLock()
flow._state.lock = threading.RLock()
flow.kickoff()
assert execution_order == ["step_1", "step_2"]
assert flow.state.counter == 2
def test_flow_with_nested_unpickleable_objects():
"""Test that Flow can handle unpickleable objects nested in containers.
Regression test for issue #3828: Verifies that unpickleable objects
nested inside dicts/lists in state don't cause crashes.
This simulates real-world scenarios where memory components or other
resources with locks might be stored in nested data structures.
"""
execution_order = []
class NestedState(BaseModel):
class Config:
arbitrary_types_allowed = True
data: dict = {}
items: list = []
class FlowWithNestedUnpickleable(Flow[NestedState]):
@start()
def step_1(self):
execution_order.append("step_1")
self.state.data["lock"] = threading.RLock()
self.state.data["value"] = 42
@listen(step_1)
def step_2(self):
execution_order.append("step_2")
self.state.items.append(threading.Lock())
self.state.items.append("normal_value")
@listen(step_2)
def step_3(self):
execution_order.append("step_3")
assert self.state.data["value"] == 42
assert len(self.state.items) == 2
flow = FlowWithNestedUnpickleable()
flow.kickoff()
assert execution_order == ["step_1", "step_2", "step_3"]
assert flow.state.data["value"] == 42
assert len(flow.state.items) == 2

View File

@@ -1,43 +0,0 @@
import pytest
from crewai.llm import LLM
@pytest.mark.parametrize(
"invalid_model,error_message",
[
(3420, "Invalid model ID: 3420. Model ID cannot be a numeric value without a provider prefix."),
("3420", "Invalid model ID: 3420. Model ID cannot be a numeric value without a provider prefix."),
(3.14, "Invalid model ID: 3.14. Model ID cannot be a numeric value without a provider prefix."),
],
)
def test_invalid_numeric_model_ids(invalid_model, error_message):
"""Test that numeric model IDs are rejected."""
with pytest.raises(ValueError, match=error_message):
LLM(model=invalid_model)
@pytest.mark.parametrize(
"valid_model",
[
"openai/gpt-4",
"gpt-3.5-turbo",
"anthropic/claude-2",
],
)
def test_valid_model_ids(valid_model):
"""Test that valid model IDs are accepted."""
llm = LLM(model=valid_model)
assert llm.model == valid_model
def test_empty_model_id():
"""Test that empty model IDs are rejected."""
with pytest.raises(ValueError, match="Invalid model ID: ''. Model ID cannot be empty or whitespace."):
LLM(model="")
def test_whitespace_model_id():
"""Test that whitespace model IDs are rejected."""
with pytest.raises(ValueError, match="Invalid model ID: ' '. Model ID cannot be empty or whitespace."):
LLM(model=" ")