From 28147528f9fe543be3ae88d630cddd875ac8cf99 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:51:12 +0000 Subject: [PATCH] fix: add async method validation to crew decorators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/crewai/src/crewai/project/annotations.py | 67 ++++++- .../test_async_decorator_validation.py | 169 ++++++++++++++++++ 2 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 lib/crewai/tests/project/test_async_decorator_validation.py diff --git a/lib/crewai/src/crewai/project/annotations.py b/lib/crewai/src/crewai/project/annotations.py index a36999052..5430a5257 100644 --- a/lib/crewai/src/crewai/project/annotations.py +++ b/lib/crewai/src/crewai/project/annotations.py @@ -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: diff --git a/lib/crewai/tests/project/test_async_decorator_validation.py b/lib/crewai/tests/project/test_async_decorator_validation.py new file mode 100644 index 000000000..bedc1fc16 --- /dev/null +++ b/lib/crewai/tests/project/test_async_decorator_validation.py @@ -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