fix: add async method validation to crew decorators

This commit adds validation to detect and reject async methods in crew
decorators (@agent, @task, @crew, @llm, @tool, @callback, @cache_handler,
@before_kickoff, @after_kickoff).

Previously, decorating async methods would silently fail at runtime with
confusing errors like "'coroutine' object has no attribute 'name'".

Now, a clear TypeError is raised at decoration time with:
- The specific decorator name that doesn't support async
- The method name that was incorrectly defined as async
- Helpful suggestions for workarounds

Fixes #3988

Co-Authored-By: João <joao@crewai.com>
This commit is contained in:
Devin AI
2025-11-28 17:51:12 +00:00
parent 2025a26fc3
commit 28147528f9
2 changed files with 231 additions and 5 deletions

View File

@@ -2,16 +2,12 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from functools import wraps
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, overload
from crewai.project.utils import memoize
if TYPE_CHECKING:
from crewai import Agent, Crew, Task
from crewai.project.wrappers import (
AfterKickoffMethod,
AgentMethod,
@@ -28,6 +24,31 @@ from crewai.project.wrappers import (
)
if TYPE_CHECKING:
from crewai import Agent, Crew, Task
def _check_async_method(meth: Callable[..., Any], decorator_name: str) -> None:
"""Check if a method is async and raise an error if so.
Args:
meth: The method to check.
decorator_name: The name of the decorator for the error message.
Raises:
TypeError: If the method is an async function.
"""
if asyncio.iscoroutinefunction(meth):
raise TypeError(
f"The @{decorator_name} decorator does not support async methods. "
f"Method '{meth.__name__}' is defined as async. "
f"Please use a synchronous method instead. "
f"If you need to perform async operations, consider: "
f"1) Creating tools/resources synchronously before crew execution, or "
f"2) Using asyncio.run() within a sync method for isolated async calls."
)
P = ParamSpec("P")
P2 = ParamSpec("P2")
R = TypeVar("R")
@@ -44,7 +65,11 @@ def before_kickoff(meth: Callable[P, R]) -> BeforeKickoffMethod[P, R]:
Returns:
A wrapped method marked for before kickoff execution.
Raises:
TypeError: If the method is an async function.
"""
_check_async_method(meth, "before_kickoff")
return BeforeKickoffMethod(meth)
@@ -56,7 +81,11 @@ def after_kickoff(meth: Callable[P, R]) -> AfterKickoffMethod[P, R]:
Returns:
A wrapped method marked for after kickoff execution.
Raises:
TypeError: If the method is an async function.
"""
_check_async_method(meth, "after_kickoff")
return AfterKickoffMethod(meth)
@@ -68,7 +97,11 @@ def task(meth: Callable[P, TaskResultT]) -> TaskMethod[P, TaskResultT]:
Returns:
A wrapped method marked as a task with memoization.
Raises:
TypeError: If the method is an async function.
"""
_check_async_method(meth, "task")
return TaskMethod(memoize(meth))
@@ -80,7 +113,11 @@ def agent(meth: Callable[P, R]) -> AgentMethod[P, R]:
Returns:
A wrapped method marked as an agent with memoization.
Raises:
TypeError: If the method is an async function.
"""
_check_async_method(meth, "agent")
return AgentMethod(memoize(meth))
@@ -92,7 +129,11 @@ def llm(meth: Callable[P, R]) -> LLMMethod[P, R]:
Returns:
A wrapped method marked as an LLM provider with memoization.
Raises:
TypeError: If the method is an async function.
"""
_check_async_method(meth, "llm")
return LLMMethod(memoize(meth))
@@ -128,7 +169,11 @@ def tool(meth: Callable[P, R]) -> ToolMethod[P, R]:
Returns:
A wrapped method marked as a tool with memoization.
Raises:
TypeError: If the method is an async function.
"""
_check_async_method(meth, "tool")
return ToolMethod(memoize(meth))
@@ -140,7 +185,11 @@ def callback(meth: Callable[P, R]) -> CallbackMethod[P, R]:
Returns:
A wrapped method marked as a callback with memoization.
Raises:
TypeError: If the method is an async function.
"""
_check_async_method(meth, "callback")
return CallbackMethod(memoize(meth))
@@ -152,7 +201,11 @@ def cache_handler(meth: Callable[P, R]) -> CacheHandlerMethod[P, R]:
Returns:
A wrapped method marked as a cache handler with memoization.
Raises:
TypeError: If the method is an async function.
"""
_check_async_method(meth, "cache_handler")
return CacheHandlerMethod(memoize(meth))
@@ -174,7 +227,11 @@ def crew(
Returns:
A wrapped method that instantiates tasks and agents before execution.
Raises:
TypeError: If the method is an async function.
"""
_check_async_method(meth, "crew")
@wraps(meth)
def wrapper(self: CrewInstance, *args: Any, **kwargs: Any) -> Crew:

View File

@@ -0,0 +1,169 @@
"""Tests for async method validation in crew decorators."""
import pytest
from crewai.project import (
after_kickoff,
agent,
before_kickoff,
cache_handler,
callback,
crew,
llm,
task,
tool,
)
class TestAsyncDecoratorValidation:
"""Test that decorators properly reject async methods with clear error messages."""
def test_agent_decorator_rejects_async_method(self):
"""Test that @agent decorator raises TypeError for async methods."""
with pytest.raises(TypeError) as exc_info:
@agent
async def async_agent(self):
return None
assert "@agent decorator does not support async methods" in str(exc_info.value)
assert "async_agent" in str(exc_info.value)
assert "synchronous method" in str(exc_info.value)
def test_task_decorator_rejects_async_method(self):
"""Test that @task decorator raises TypeError for async methods."""
with pytest.raises(TypeError) as exc_info:
@task
async def async_task(self):
return None
assert "@task decorator does not support async methods" in str(exc_info.value)
assert "async_task" in str(exc_info.value)
assert "synchronous method" in str(exc_info.value)
def test_crew_decorator_rejects_async_method(self):
"""Test that @crew decorator raises TypeError for async methods."""
with pytest.raises(TypeError) as exc_info:
@crew
async def async_crew(self):
return None
assert "@crew decorator does not support async methods" in str(exc_info.value)
assert "async_crew" in str(exc_info.value)
assert "synchronous method" in str(exc_info.value)
def test_llm_decorator_rejects_async_method(self):
"""Test that @llm decorator raises TypeError for async methods."""
with pytest.raises(TypeError) as exc_info:
@llm
async def async_llm(self):
return None
assert "@llm decorator does not support async methods" in str(exc_info.value)
assert "async_llm" in str(exc_info.value)
assert "synchronous method" in str(exc_info.value)
def test_tool_decorator_rejects_async_method(self):
"""Test that @tool decorator raises TypeError for async methods."""
with pytest.raises(TypeError) as exc_info:
@tool
async def async_tool(self):
return None
assert "@tool decorator does not support async methods" in str(exc_info.value)
assert "async_tool" in str(exc_info.value)
assert "synchronous method" in str(exc_info.value)
def test_callback_decorator_rejects_async_method(self):
"""Test that @callback decorator raises TypeError for async methods."""
with pytest.raises(TypeError) as exc_info:
@callback
async def async_callback(self):
return None
assert "@callback decorator does not support async methods" in str(exc_info.value)
assert "async_callback" in str(exc_info.value)
assert "synchronous method" in str(exc_info.value)
def test_cache_handler_decorator_rejects_async_method(self):
"""Test that @cache_handler decorator raises TypeError for async methods."""
with pytest.raises(TypeError) as exc_info:
@cache_handler
async def async_cache_handler(self):
return None
assert "@cache_handler decorator does not support async methods" in str(
exc_info.value
)
assert "async_cache_handler" in str(exc_info.value)
assert "synchronous method" in str(exc_info.value)
def test_before_kickoff_decorator_rejects_async_method(self):
"""Test that @before_kickoff decorator raises TypeError for async methods."""
with pytest.raises(TypeError) as exc_info:
@before_kickoff
async def async_before_kickoff(self, inputs):
return inputs
assert "@before_kickoff decorator does not support async methods" in str(
exc_info.value
)
assert "async_before_kickoff" in str(exc_info.value)
assert "synchronous method" in str(exc_info.value)
def test_after_kickoff_decorator_rejects_async_method(self):
"""Test that @after_kickoff decorator raises TypeError for async methods."""
with pytest.raises(TypeError) as exc_info:
@after_kickoff
async def async_after_kickoff(self, outputs):
return outputs
assert "@after_kickoff decorator does not support async methods" in str(
exc_info.value
)
assert "async_after_kickoff" in str(exc_info.value)
assert "synchronous method" in str(exc_info.value)
def test_sync_methods_still_work(self):
"""Test that synchronous methods are still properly decorated."""
from crewai import Agent, Task
@agent
def sync_agent(self):
return Agent(
role="Test Agent", goal="Test Goal", backstory="Test Backstory"
)
@task
def sync_task(self):
return Task(description="Test Description", expected_output="Test Output")
class TestCrew:
pass
test_instance = TestCrew()
agent_result = sync_agent(test_instance)
task_result = sync_task(test_instance)
assert agent_result.role == "Test Agent"
assert task_result.description == "Test Description"
def test_error_message_includes_workaround_suggestions(self):
"""Test that error messages include helpful workaround suggestions."""
with pytest.raises(TypeError) as exc_info:
@agent
async def async_agent_with_tools(self):
return None
error_message = str(exc_info.value)
assert "Creating tools/resources synchronously" in error_message
assert "asyncio.run()" in error_message