From 6f6e853cf34f074114b6d5c11a39de9d0e8c9ecf Mon Sep 17 00:00:00 2001 From: rain <1504569896@qq.com> Date: Tue, 15 Sep 2026 21:22:57 +0800 Subject: [PATCH] fix(tools): await coroutines returned by sync wrappers in CrewStructuredTool.ainvoke (#7474) BaseTool.to_structured_tool() stores func=self._run, and Tool._run is a sync method that returns self.func(...) as-is - for an async tool that is a bare coroutine. Inside CrewStructuredTool.ainvoke the sync branch dispatches through run_in_executor, so inspect.iscoroutinefunction(self.func) is False and the coroutine object was handed straight back to the caller: the tool body never ran, the observation fed to the LLM was the literal '' repr, and the coroutine was GC'd with 'coroutine was never awaited'. Every sibling branch already resolves this (invoke() checks asyncio.iscoroutine, BaseTool.run and Tool.run re-run via asyncio.run, Tool._arun awaits awaitables); only the async branch of CrewStructuredTool missed the check. Await the result when it is awaitable before returning it. Regression tests cover the direct sync-wrapper case and the @tool(async_fn).to_structured_tool() path from the issue. --- .../src/crewai/tools/structured_tool.py | 9 ++++- .../tests/tools/test_structured_tool.py | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/lib/crewai/src/crewai/tools/structured_tool.py b/lib/crewai/src/crewai/tools/structured_tool.py index c7191de40c..41f1da4e21 100644 --- a/lib/crewai/src/crewai/tools/structured_tool.py +++ b/lib/crewai/src/crewai/tools/structured_tool.py @@ -407,9 +407,16 @@ async def ainvoke( return await self.func(**parsed_args, **kwargs) import asyncio - return await asyncio.get_event_loop().run_in_executor( + result = await asyncio.get_event_loop().run_in_executor( None, lambda: self.func(**parsed_args, **kwargs) ) + # A sync wrapper around an async tool (e.g. BaseTool.to_structured_tool + # storing Tool._run as func) hands a bare coroutine back from the + # executor; awaiting it here keeps the caller from receiving the + # coroutine object itself. + if inspect.isawaitable(result): + return await result + return result except Exception: raise diff --git a/lib/crewai/tests/tools/test_structured_tool.py b/lib/crewai/tests/tools/test_structured_tool.py index 0241abbcff..9a911a2fab 100644 --- a/lib/crewai/tests/tools/test_structured_tool.py +++ b/lib/crewai/tests/tools/test_structured_tool.py @@ -519,3 +519,42 @@ def failing_function(should_fail: bool) -> str: tool.invoke({"should_fail": True}) assert call_count == 1 + + +@pytest.mark.asyncio +async def test_ainvoke_awaits_coroutine_from_sync_wrapper(): + """ainvoke must await a coroutine handed back by a sync wrapper function.""" + calls: list[str] = [] + + async def async_body(q: str) -> str: + calls.append(q) + return f"data:{q}" + + def sync_wrapper(q: str) -> object: + """Sync wrapper that returns the coroutine from async_body.""" + return async_body(q) + + tool = CrewStructuredTool.from_function(func=sync_wrapper, name="wrapped_async_tool") + + result = await tool.ainvoke(input={"q": "hello"}) + + assert result == "data:hello" + assert calls == ["hello"] + + +@pytest.mark.asyncio +async def test_ainvoke_through_to_structured_tool_resolves_async_tool_result(): + """An @tool-decorated async function wraps the sync Tool._run wrapper as func; ainvoke + must resolve the coroutine it returns instead of handing the caller a bare coroutine.""" + from crewai.tools import tool + + @tool + async def fetch_data(q: str) -> str: + """Fetch data for q.""" + return f"data:{q}" + + structured = fetch_data.to_structured_tool() + + result = await structured.ainvoke(input={"q": "hello"}) + + assert result == "data:hello"