Skip to content

[BUG] CrewStructuredTool.ainvoke() returns the un-awaited coroutine when func is a sync wrapper around an async tool, so async agents feed the LLM "<coroutine object ...>" instead of the tool result #7474

Description

@BlueX888

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions