Description
For an async tool created with the @tool decorator (or Tool(func=async_fn)), the async execution path drops the tool's coroutine instead of awaiting it.
BaseTool.to_structured_tool() stores func=self._run (lib/crewai/src/crewai/tools/base_tool.py:413), and Tool._run is a sync method that just returns self.func(...) (base_tool.py:556-566). So inside CrewStructuredTool.ainvoke() inspect.iscoroutinefunction(self.func) is False, the call is dispatched through run_in_executor, and the coroutine object it returns is handed straight back to the caller:
# lib/crewai/src/crewai/tools/structured_tool.py:404-412 (main @ 9393a47)
if inspect.iscoroutinefunction(self.func):
return await self.func(**parsed_args, **kwargs)
import asyncio
return await asyncio.get_event_loop().run_in_executor(
None, lambda: self.func(**parsed_args, **kwargs)
)
Every sibling branch handles this case:
CrewStructuredTool.invoke() lines 440-447: result = self.func(...) then if asyncio.iscoroutine(result): return asyncio.run(result).
BaseTool.run lines 339-340, Tool.run lines 549-551.
Tool._arun lines 599-602: if _is_awaitable(result): return await result.
Only the async branch of CrewStructuredTool is missing the check.
Effect: crew.kickoff_async() -> utilities/tool_utils.py::aexecute_tool_and_check_finality -> ToolUsage.ause -> _ause -> tool.ainvoke() returns a coroutine, the tool body never executes, and the observation fed back to the LLM is the literal string <coroutine object fetch_data at 0x...>. The coroutine is then garbage-collected with RuntimeWarning: coroutine 'fetch_data' was never awaited.
Root cause: lib/crewai/src/crewai/tools/structured_tool.py:404-412 — the async branch resolves the sync wrapper's return value but, unlike invoke() at lines 440-447, never checks asyncio.iscoroutine(result) / inspect.isawaitable(result).
Steps to Reproduce
Environment
- crewAI: 1.15.21 (
main @ 9393a47f313a0544db15693db9bcc48585f30ef5)
- Python: 3.13.15
- OS: macOS 26.6.2 (Darwin 25.6.0, arm64) — not in the dropdown
- Virtual environment: venv,
crewai installed from this checkout (pip install -e lib/crewai)
- crewAI Tools version: N/A (repro uses
crewai.tools core only)
Script (repro_ainvoke_async_tool.py):
import asyncio
import inspect
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()
print("func:", type(structured.func).__name__,
"| iscoroutinefunction:", inspect.iscoroutinefunction(structured.func))
# sync branch and async branch of the very same structured tool
print("invoke() ->", repr(fetch_data.to_structured_tool().invoke({"q": "x"})))
out = asyncio.run(structured.ainvoke({"q": "x"}))
print("ainvoke() ->", type(out).__name__, repr(out))
The same shape is also reproduced end-to-end through the real agent path (Crew(..., executor_class=CrewAgentExecutor).akickoff() -> ToolUsage._ause -> ainvoke), which is where the observation string reaches the model.
Expected behavior
await structured_tool.ainvoke({"q": "x"}) should return "data:x", exactly like structured_tool.invoke({"q": "x"}) and Tool.arun() do, with the async body executed once.
Concrete basis:
Tool.func is annotated Callable[P, R | Awaitable[R]] (lib/crewai/src/crewai/tools/base_tool.py:530) — a callable that returns an awaitable is explicitly supported, and to_structured_tool() stores that callable.
- The parallel branches above already normalize it:
CrewStructuredTool.invoke lines 440-447, BaseTool.run lines 339-340, Tool.run lines 549-551, Tool._arun lines 599-602.
lib/crewai/tests/tools/test_async_tools.py:107-147 asserts a decorated async tool works through run() and arun().
docs/edge/en/concepts/tools.mdx:239-260 documents async @tool functions as a supported feature.
Screenshots/Code snippets
Direct reproduction (repro_ainvoke_async_tool.py, HEAD 9393a47):
func: method | iscoroutinefunction: False
invoke() -> 'data:x'
ainvoke() -> coroutine <coroutine object fetch_data at 0x110deff40>
agent observation -> '<coroutine object fetch_data at 0x1112b4100>'
<sys>:0: RuntimeWarning: coroutine 'fetch_data' was never awaited
Independent verifier run 1 (same commit):
func type : method
iscoro(func) : False
invoke() : 'data:sync'
ainvoke() type : coroutine
ainvoke() repr : <coroutine object fetch_data at 0x110666c80>
body executed? : [] (empty means the coroutine never ran)
# e2e via Crew(executor_class=CrewAgentExecutor).akickoff():
CALLS : [] | OBSERVATION CONTAINS: <coroutine object fetch_data at 0x10c346ec0> | RuntimeWarning: coroutine 'fetch_data' was never awaited
Independent verifier run 2 (same commit):
func: method | iscoroutinefunction: False
invoke() -> 'data:x'
ainvoke() -> coroutine <coroutine object fetch_data at 0x10c4d3f40>
agent observation -> '<coroutine object fetch_data at 0x10c98c100>'
<sys>:0: RuntimeWarning: coroutine 'fetch_data' was never awaited
>>AINVOKE fetch_data is_corofunc= False -> coroutine <coroutine object fetch_data at 0x10cecaf80>
Observation: <coroutine object fetch_data at 0x10cecaf80>
Operating System
Other (specify in additional context)
Python Version
3.13 (reproduced on 3.13.15)
crewAI Version
1.15.21 (main @ 9393a47)
crewAI Tools Version
N/A (core package; repro only uses crewai.tools)
Virtual Environment
Venv
Evidence
See "Screenshots/Code snippets" above: two independent reproductions plus the direct run on the same commit, all showing ainvoke() returning a coroutine object and the observation reaching the agent as <coroutine object fetch_data at 0x...>, followed by RuntimeWarning: coroutine 'fetch_data' was never awaited. Call chain: crew.kickoff_async() -> CrewAgentExecutor._ainvoke_loop_react (lib/crewai/src/crewai/agents/crew_agent_executor.py:1168) -> utilities/tool_utils.py::aexecute_tool_and_check_finality (line 35) -> ToolUsage.ause (tools/tool_usage.py:187) -> ToolUsage._ause (line 238) -> CrewStructuredTool.ainvoke.
Possible Solution
Happy to open a PR that mirrors invoke()'s check inside ainvoke(): after resolving the result on either branch, await it when it is awaitable (inspect.isawaitable(result) / asyncio.iscoroutine(result)), matching Tool._arun. I can also mirror the usage-count/limit handling and add an async-path regression test if that fits your preference. Happy to be assigned.
Additional context
Related issue/PR references found by collision checks: #6611 ([BUG] Async tools are not awaited natively..., closed 2026-08-27 as stale/NOT_PLANNED) and its closed PR #6699, plus #5901, #5969, #4832 — these are the same area but a different symptom (the experimental native-function-calling path calling sync BaseTool.run, and the asyncio.get_event_loop() deprecation on these lines); none covers the ainvoke / sync-wrapper case. Open PR #7359 also touches structured_tool.py but is tracing-only (execution span export). No open issue or PR was found for this defect.
AI disclosure: this issue was authored by an AI agent (Claude Code) and has been labelled llm-generated, per .github/CONTRIBUTING.md.
Description
For an async tool created with the
@tooldecorator (orTool(func=async_fn)), the async execution path drops the tool's coroutine instead of awaiting it.BaseTool.to_structured_tool()storesfunc=self._run(lib/crewai/src/crewai/tools/base_tool.py:413), andTool._runis a sync method that just returnsself.func(...)(base_tool.py:556-566). So insideCrewStructuredTool.ainvoke()inspect.iscoroutinefunction(self.func)isFalse, the call is dispatched throughrun_in_executor, and the coroutine object it returns is handed straight back to the caller:Every sibling branch handles this case:
CrewStructuredTool.invoke()lines 440-447:result = self.func(...)thenif asyncio.iscoroutine(result): return asyncio.run(result).BaseTool.runlines 339-340,Tool.runlines 549-551.Tool._arunlines 599-602:if _is_awaitable(result): return await result.Only the async branch of
CrewStructuredToolis missing the check.Effect:
crew.kickoff_async()->utilities/tool_utils.py::aexecute_tool_and_check_finality->ToolUsage.ause->_ause->tool.ainvoke()returns a coroutine, the tool body never executes, and the observation fed back to the LLM is the literal string<coroutine object fetch_data at 0x...>. The coroutine is then garbage-collected withRuntimeWarning: coroutine 'fetch_data' was never awaited.Root cause:
lib/crewai/src/crewai/tools/structured_tool.py:404-412— the async branch resolves the sync wrapper's return value but, unlikeinvoke()at lines 440-447, never checksasyncio.iscoroutine(result)/inspect.isawaitable(result).Steps to Reproduce
Environment
main@9393a47f313a0544db15693db9bcc48585f30ef5)crewaiinstalled from this checkout (pip install -e lib/crewai)crewai.toolscore only)Script (
repro_ainvoke_async_tool.py):The same shape is also reproduced end-to-end through the real agent path (
Crew(..., executor_class=CrewAgentExecutor).akickoff()->ToolUsage._ause->ainvoke), which is where the observation string reaches the model.Expected behavior
await structured_tool.ainvoke({"q": "x"})should return"data:x", exactly likestructured_tool.invoke({"q": "x"})andTool.arun()do, with the async body executed once.Concrete basis:
Tool.funcis annotatedCallable[P, R | Awaitable[R]](lib/crewai/src/crewai/tools/base_tool.py:530) — a callable that returns an awaitable is explicitly supported, andto_structured_tool()stores that callable.CrewStructuredTool.invokelines 440-447,BaseTool.runlines 339-340,Tool.runlines 549-551,Tool._arunlines 599-602.lib/crewai/tests/tools/test_async_tools.py:107-147asserts a decorated async tool works throughrun()andarun().docs/edge/en/concepts/tools.mdx:239-260documents async@toolfunctions as a supported feature.Screenshots/Code snippets
Direct reproduction (
repro_ainvoke_async_tool.py, HEAD9393a47):Independent verifier run 1 (same commit):
Independent verifier run 2 (same commit):
Operating System
Other (specify in additional context)
Python Version
3.13 (reproduced on 3.13.15)
crewAI Version
1.15.21 (main @ 9393a47)
crewAI Tools Version
N/A (core package; repro only uses
crewai.tools)Virtual Environment
Venv
Evidence
See "Screenshots/Code snippets" above: two independent reproductions plus the direct run on the same commit, all showing
ainvoke()returning a coroutine object and the observation reaching the agent as<coroutine object fetch_data at 0x...>, followed byRuntimeWarning: coroutine 'fetch_data' was never awaited. Call chain:crew.kickoff_async()->CrewAgentExecutor._ainvoke_loop_react(lib/crewai/src/crewai/agents/crew_agent_executor.py:1168) ->utilities/tool_utils.py::aexecute_tool_and_check_finality(line 35) ->ToolUsage.ause(tools/tool_usage.py:187) ->ToolUsage._ause(line 238) ->CrewStructuredTool.ainvoke.Possible Solution
Happy to open a PR that mirrors
invoke()'s check insideainvoke(): after resolving the result on either branch, await it when it is awaitable (inspect.isawaitable(result)/asyncio.iscoroutine(result)), matchingTool._arun. I can also mirror the usage-count/limit handling and add an async-path regression test if that fits your preference. Happy to be assigned.Additional context
Related issue/PR references found by collision checks: #6611 (
[BUG] Async tools are not awaited natively..., closed 2026-08-27 as stale/NOT_PLANNED) and its closed PR #6699, plus #5901, #5969, #4832 — these are the same area but a different symptom (the experimental native-function-calling path calling syncBaseTool.run, and theasyncio.get_event_loop()deprecation on these lines); none covers theainvoke/ sync-wrapper case. Open PR #7359 also touchesstructured_tool.pybut is tracing-only (execution span export). No open issue or PR was found for this defect.AI disclosure: this issue was authored by an AI agent (Claude Code) and has been labelled
llm-generated, per.github/CONTRIBUTING.md.