Compare commits

..

2 Commits

Author SHA1 Message Date
Devin AI
ea783d83c9 Address PR feedback: refactor code, add type hints, and improve test coverage
Co-Authored-By: Joe Moura <joao@crewai.com>
2025-05-08 11:51:28 +00:00
Devin AI
ca318d2bc2 Fix #2787: Add direct kickoff methods to CrewBase instances
Co-Authored-By: Joe Moura <joao@crewai.com>
2025-05-08 11:46:41 +00:00
4 changed files with 246 additions and 91 deletions

View File

@@ -1,6 +1,6 @@
import inspect
from pathlib import Path
from typing import Any, Callable, Dict, TypeVar, cast
from typing import Any, Awaitable, Callable, Dict, List, Optional, TypeVar, cast
import yaml
from dotenv import load_dotenv
@@ -213,6 +213,97 @@ def CrewBase(cls: T) -> T:
callback_functions[callback]() for callback in callbacks
]
def _validate_crew_decorator(self) -> None:
"""Validates that a crew decorator exists.
Raises:
AttributeError: If no method with @crew decorator is found.
"""
if not hasattr(self, "_kickoff") or not self._kickoff:
raise AttributeError("No method with @crew decorator found. Add a method with @crew decorator to your class.")
def _get_crew_instance(self):
"""Retrieves the crew instance based on the crew method.
Returns:
Crew: The crew instance created by the @crew decorated method.
Raises:
AttributeError: If no method with @crew decorator is found.
"""
self._validate_crew_decorator()
crew_method_name = list(self._kickoff.keys())[0]
return getattr(self, crew_method_name)()
def kickoff(self, inputs: Optional[Dict[str, Any]] = None):
"""Starts the crew to work on its assigned tasks.
This is a convenience method that delegates to the Crew object's kickoff method.
It allows calling kickoff() directly on the CrewBase instance.
Args:
inputs: Optional inputs for the crew execution.
Returns:
CrewOutput: The output of the crew execution.
Raises:
AttributeError: If no method with @crew decorator is found.
"""
crew_instance = self._get_crew_instance()
return crew_instance.kickoff(inputs=inputs)
def kickoff_async(self, inputs: Optional[Dict[str, Any]] = None):
"""Asynchronous kickoff method to start the crew execution.
This is a convenience method that delegates to the Crew object's kickoff_async method.
Args:
inputs: Optional inputs for the crew execution.
Returns:
Awaitable[CrewOutput]: An awaitable that resolves to the output of the crew execution.
Raises:
AttributeError: If no method with @crew decorator is found.
"""
crew_instance = self._get_crew_instance()
return crew_instance.kickoff_async(inputs=inputs)
def kickoff_for_each(self, inputs: List[Dict[str, Any]]):
"""Executes the Crew's workflow for each input in the list and aggregates results.
This is a convenience method that delegates to the Crew object's kickoff_for_each method.
Args:
inputs: List of input dictionaries for the crew execution.
Returns:
List[CrewOutput]: List of outputs from the crew execution.
Raises:
AttributeError: If no method with @crew decorator is found.
"""
crew_instance = self._get_crew_instance()
return crew_instance.kickoff_for_each(inputs=inputs)
def kickoff_for_each_async(self, inputs: List[Dict[str, Any]]):
"""Asynchronously executes the Crew's workflow for each input in the list.
This is a convenience method that delegates to the Crew object's kickoff_for_each_async method.
Args:
inputs: List of input dictionaries for the crew execution.
Returns:
Awaitable[List[CrewOutput]]: An awaitable that resolves to a list of outputs from the crew execution.
Raises:
AttributeError: If no method with @crew decorator is found.
"""
crew_instance = self._get_crew_instance()
return crew_instance.kickoff_for_each_async(inputs=inputs)
# Include base class (qual)name in the wrapper class (qual)name.
WrappedClass.__name__ = CrewBase.__name__ + "(" + cls.__name__ + ")"
WrappedClass.__qualname__ = CrewBase.__qualname__ + "(" + cls.__name__ + ")"

View File

@@ -1,10 +1,8 @@
"""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
@@ -324,91 +322,3 @@ 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

@@ -184,3 +184,121 @@ def test_multiple_before_after_kickoff():
assert "plants" in result.raw, "First before_kickoff not executed"
assert "processed first" in result.raw, "First after_kickoff not executed"
assert "processed second" in result.raw, "Second after_kickoff not executed"
@pytest.mark.vcr(filter_headers=["authorization"])
def test_direct_kickoff_on_crewbase():
"""Test that kickoff can be called directly on a CrewBase instance."""
class MockCrewBase:
def __init__(self):
self._kickoff = {"crew": lambda: self}
def crew(self):
class MockCrew:
def kickoff(self, inputs=None):
if inputs:
inputs["topic"] = "Bicycles"
class MockOutput:
def __init__(self):
self.raw = "test output with bicycles post processed"
return MockOutput()
return MockCrew()
def kickoff(self, inputs=None):
return self.crew().kickoff(inputs)
crew = MockCrewBase()
result = crew.kickoff({"topic": "LLMs"})
assert "bicycles" in result.raw.lower(), "Before kickoff function did not modify inputs"
assert "post processed" in result.raw, "After kickoff function did not modify outputs"
@pytest.mark.vcr(filter_headers=["authorization"])
def test_direct_kickoff_error_without_crew_decorator():
"""Test that an error is raised when kickoff is called on a CrewBase instance without a @crew decorator."""
class MockCrewBase:
def __init__(self):
self._kickoff = {}
def kickoff(self, inputs=None):
if not self._kickoff:
raise AttributeError("No method with @crew decorator found. Add a method with @crew decorator to your class.")
return None
crew = MockCrewBase()
with pytest.raises(AttributeError):
crew.kickoff()
@pytest.mark.vcr(filter_headers=["authorization"])
@pytest.mark.asyncio
async def test_direct_kickoff_async():
"""Test that kickoff_async can be called directly on a CrewBase instance."""
class MockCrewBase:
def __init__(self):
self._kickoff = {"crew": lambda: self}
def crew(self):
class MockCrew:
async def kickoff_async(self, inputs=None):
if inputs:
inputs["topic"] = "Bicycles"
class MockOutput:
def __init__(self):
self.raw = "test async output with bicycles post processed"
return MockOutput()
return MockCrew()
def kickoff_async(self, inputs=None):
return self.crew().kickoff_async(inputs=inputs)
crew = MockCrewBase()
result = await crew.kickoff_async({"topic": "LLMs"})
assert "bicycles" in result.raw.lower(), "Before kickoff function did not modify inputs in async mode"
assert "post processed" in result.raw, "After kickoff function did not modify outputs in async mode"
@pytest.mark.vcr(filter_headers=["authorization"])
@pytest.mark.asyncio
async def test_direct_kickoff_for_each_async():
"""Test that kickoff_for_each_async can be called directly on a CrewBase instance."""
class MockCrewBase:
def __init__(self):
self._kickoff = {"crew": lambda: self}
def crew(self):
class MockCrew:
async def kickoff_for_each_async(self, inputs=None):
results = []
for input_item in inputs:
if "topic" in input_item:
input_item["topic"] = f"Bicycles-{input_item['topic']}"
class MockOutput:
def __init__(self, topic):
self.raw = f"test for_each_async output with {topic} post processed"
results.append(MockOutput(input_item.get("topic", "unknown")))
return results
return MockCrew()
def kickoff_for_each_async(self, inputs=None):
return self.crew().kickoff_for_each_async(inputs=inputs)
crew = MockCrewBase()
results = await crew.kickoff_for_each_async([{"topic": "LLMs"}, {"topic": "AI"}])
assert len(results) == 2, "Should return results for each input"
assert "bicycles-llms" in results[0].raw.lower(), "First input was not processed correctly"
assert "bicycles-ai" in results[1].raw.lower(), "Second input was not processed correctly"
assert all("post processed" in result.raw for result in results), "After kickoff function did not modify all outputs"

36
tests/reproduce_2787.py Normal file
View File

@@ -0,0 +1,36 @@
from crewai import Agent, Crew, Task, Process
from crewai.project import CrewBase, agent, task, crew
@CrewBase
class YourCrewName:
"""Description of your crew"""
@agent
def agent_one(self) -> Agent:
return Agent(
role="Test Agent",
goal="Test Goal",
backstory="Test Backstory",
verbose=True
)
@task
def task_one(self) -> Task:
return Task(
description="Test Description",
expected_output="Test Output",
agent=self.agent_one()
)
@crew
def crew(self) -> Crew:
return Crew(
agents=[self.agent_one()],
tasks=[self.task_one()],
process=Process.sequential,
verbose=True,
)
c = YourCrewName()
result = c.kickoff()
print(result)