Skip to content

fix: #6611 - Add async native tool execution to experimental executor - #6699

Open
Diwak4r wants to merge 3 commits into
crewAIInc:mainfrom
Diwak4r:main
Open

fix: #6611 - Add async native tool execution to experimental executor#6699
Diwak4r wants to merge 3 commits into
crewAIInc:mainfrom
Diwak4r:main

Conversation

@Diwak4r

@Diwak4r Diwak4r commented Jul 28, 2026

Copy link
Copy Markdown

Summary

Fixes #6611 - Async tools are now awaited natively on the running event loop in the experimental native function calling executor, consistent with the ReAct executor.

Changes

  • lib/crewai/src/crewai/experimental/agent_executor.py:
    • Added _execute_single_native_tool_call_async method that uses CrewStructuredTool.ainvoke() for proper async tool execution
    • Changed execute_native_tool to async method using asyncio.gather() for parallel execution
    • Sync tools still work via fallback to _available_functions (thread pool)
    • Removed ThreadPoolExecutor import, now uses pure async
  • lib/crewai/tests/agents/test_agent_executor.py: Updated tests to asyncio.run() the async method

Root Cause

The experimental native function calling executor called tools synchronously via tool.run() which bridged coroutines with asyncio.run() - creating a fresh nested event loop per call in a worker thread. The ReAct executor correctly uses CrewStructuredTool.ainvoke() which detects coroutine functions and awaits them natively on the running loop.

Fix

The fix routes native tool calls through the same async-aware dispatch pattern:

  1. structured_tool.ainvoke(args_dict) - awaits coroutine functions natively on the current event loop
  2. Falls back to sync function for tools without structured_tool (uses thread pool via run_in_executor)
  3. Parallel execution via asyncio.gather() instead of ThreadPoolExecutor

Testing

  • All 4 native tool execution tests pass
  • All 15 async agent executor tests pass
  • All 11 async crew tests pass
  • Manual verification: async tool _run now executes on MainThread (is_main=True) instead of worker thread

Diwak4r added 2 commits July 28, 2026 12:39
…_original_tool_calling for consistent error handling
… executor

- Added async _execute_single_native_tool_call_async method using CrewStructuredTool.ainvoke for proper async tool support
- Changed execute_native_tool to async method using asyncio.gather for parallel execution
- Sync tools still work via fallback to _available_functions in thread pool
- Async tools now run on caller's event loop (MainThread) instead of via asyncio.run() in worker thread
- Updated test_execute_native_tool tests to await the async method
- Matches ReAct executor's async-aware dispatch pattern
Copilot AI review requested due to automatic review settings July 28, 2026 10:06
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Native tool execution now uses async orchestration with awaited structured tools, concurrent and sequential paths, preserved lifecycle behavior, standardized argument errors, and updated tests. Uploader factory failure paths now raise PermanentUploadError with specific messages.

Changes

Native tool async execution

Layer / File(s) Summary
Async native-call orchestration
lib/crewai/src/crewai/experimental/agent_executor.py
execute_native_tool is asynchronous, uses asyncio.gather for safe parallel calls, and preserves sequential and short-circuit paths.
Async tool invocation and lifecycle
lib/crewai/src/crewai/experimental/agent_executor.py, lib/crewai/src/crewai/tools/tool_usage.py
Native calls use awaited structured tools when available, retain parsing, caching, limits, hooks, and events, and normalize argument failures to ToolUsageError.
Async execution test coverage
lib/crewai/tests/agents/test_agent_executor.py
Native execution tests invoke the asynchronous method while covering parallel, sequential, and short-circuit scenarios.

Uploader error standardization

Layer / File(s) Summary
Uploader failure handling
lib/crewai-files/src/crewai_files/uploaders/factory.py
Uploader dependency, Bedrock configuration, and unmatched-provider failures now raise PermanentUploadError with specific messages.

Sequence Diagram(s)

sequenceDiagram
  participant AgentExecutor
  participant CrewStructuredTool
  participant ToolFunction
  AgentExecutor->>CrewStructuredTool: invoke native tool asynchronously
  CrewStructuredTool->>ToolFunction: await async tool function
  ToolFunction-->>AgentExecutor: return execution result
Loading

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes unrelated uploader error-handling and tool-validation changes that are not part of the async executor fix. Remove the unrelated uploader and tool-validation changes, or split them into a separate PR focused on async native tool execution.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: async native tool execution in the experimental executor.
Description check ✅ Passed The description is on-topic and matches the async native tool execution fix and test updates.
Linked Issues check ✅ Passed The async executor now awaits native tool calls on the event loop and keeps sync fallback and concurrency, matching #6611.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/crewai/tests/agents/test_agent_executor.py (1)

1243-1277: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

No test exercises an actual async tool, and the parallelism assertion is too loose to detect serialization.

The PR's core behavior — awaiting coroutine tools natively on the running loop (issue #6611) — is untested. Every tool here is a plain def with time.sleep, which goes down the sync fallback branch.

Separately, assert elapsed < 0.5 passes even when the two 0.2s calls run strictly sequentially (~0.4s), so it no longer proves concurrency; see the blocking-fallback concern at lib/crewai/src/crewai/experimental/agent_executor.py Line 2203. Tightening to < 0.35 would make the assertion meaningful.

Suggest adding a case with an async def tool asserting it is awaited on the caller's loop (e.g. capture asyncio.get_running_loop() inside the tool and compare), plus one asserting two asyncio.sleep(0.2) tools complete in ~0.2s.

As per coding guidelines, "Write unit tests for new functionality that focus on behavior rather than implementation details."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/tests/agents/test_agent_executor.py` around lines 1243 - 1277,
Expand test_execute_native_tool_runs_parallel_for_multiple_calls to use async
tools with asyncio.sleep and assert both complete below 0.35 seconds, proving
concurrent execution rather than serialization. Add a separate async-tool test
that captures asyncio.get_running_loop() inside the tool and verifies it matches
the caller’s loop, while preserving result and tool-message assertions.

Source: Coding guidelines

🧹 Nitpick comments (3)
lib/crewai/src/crewai/tools/tool_usage.py (1)

846-854: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Swallowing the original exception loses argument-parse diagnostics.

_tool_calling (Line 878) prints and formats the caught exception into the agent-visible tool_usage_error message. Since this path now always raises the generic tool_arguments_error string, the concrete cause (malformed JSON, repair failure, schema mismatch) is no longer visible to either the agent or the logs — that detail is what lets the LLM correct its own arguments on retry. Consider chaining the original.

♻️ Preserve the cause
-        except Exception:
+        except Exception as e:
             if raise_error:
-                raise ToolUsageError(f"{I18N_DEFAULT.errors('tool_arguments_error')}")
+                raise ToolUsageError(
+                    f"{I18N_DEFAULT.errors('tool_arguments_error')}"
+                ) from e
             return ToolUsageError(f"{I18N_DEFAULT.errors('tool_arguments_error')}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/tools/tool_usage.py` around lines 846 - 854, Preserve
the original argument-parsing exception in the exception branch of _tool_calling
by chaining it when raising or returning ToolUsageError, while retaining the
existing generic tool_arguments_error message and raise_error behavior. Ensure
callers can inspect the concrete cause for malformed JSON, repair, or schema
failures.
lib/crewai/src/crewai/experimental/agent_executor.py (2)

1736-1749: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider return_exceptions=True for the parallel batch.

_execute_single_native_tool_call_async only guards the tool invocation itself; failures in hooks, event emission, or format_native_tool_output_for_agent propagate and will abort the whole batch, discarding results from sibling calls that already completed. execute_todos_parallel (Line 1240) already uses return_exceptions=True for this reason.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/experimental/agent_executor.py` around lines 1736 -
1749, The parallel branch in the native tool execution flow should preserve
sibling results when one task fails outside the tool-invocation guard. Update
the asyncio.gather call in the should_parallelize path to use
return_exceptions=True, then handle gathered exceptions consistently with
execute_todos_parallel while retaining successful results.

2051-2058: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Refactor the duplicated native tool invocation flow.

_execute_single_native_tool_call_async copies the sync call path plus most hook/cache/event logic from _execute_single_native_tool_call; only the structured-tool invocation differs (await structured_tool.ainvoke(...) vs tool_func(**args_dict)). Extract the shared resolution/cache/hooks into helpers so cache-key/hook-order fixes are applied in one place, and drop the private experimental sync method if it has no remaining callers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/experimental/agent_executor.py` around lines 2051 -
2058, Refactor _execute_single_native_tool_call_async and
_execute_single_native_tool_call to share helpers for tool resolution, cache
access, hooks, and event handling, leaving only the invocation operation
different (ainvoke asynchronously versus direct synchronous execution). Preserve
the existing cache-key and hook ordering in the shared flow, and remove the
private synchronous method if no callers remain after consolidation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/crewai/src/crewai/experimental/agent_executor.py`:
- Around line 2203-2224: Update the synchronous fallback in the native tool
execution path identified by _available_functions and tool_func so
tool_func(**args_dict) runs via asyncio.to_thread instead of directly on the
event loop. Await the offloaded call while preserving the existing raw-result,
caching, and formatting flow for synchronous tools.

---

Outside diff comments:
In `@lib/crewai/tests/agents/test_agent_executor.py`:
- Around line 1243-1277: Expand
test_execute_native_tool_runs_parallel_for_multiple_calls to use async tools
with asyncio.sleep and assert both complete below 0.35 seconds, proving
concurrent execution rather than serialization. Add a separate async-tool test
that captures asyncio.get_running_loop() inside the tool and verifies it matches
the caller’s loop, while preserving result and tool-message assertions.

---

Nitpick comments:
In `@lib/crewai/src/crewai/experimental/agent_executor.py`:
- Around line 1736-1749: The parallel branch in the native tool execution flow
should preserve sibling results when one task fails outside the tool-invocation
guard. Update the asyncio.gather call in the should_parallelize path to use
return_exceptions=True, then handle gathered exceptions consistently with
execute_todos_parallel while retaining successful results.
- Around line 2051-2058: Refactor _execute_single_native_tool_call_async and
_execute_single_native_tool_call to share helpers for tool resolution, cache
access, hooks, and event handling, leaving only the invocation operation
different (ainvoke asynchronously versus direct synchronous execution). Preserve
the existing cache-key and hook ordering in the shared flow, and remove the
private synchronous method if no callers remain after consolidation.

In `@lib/crewai/src/crewai/tools/tool_usage.py`:
- Around line 846-854: Preserve the original argument-parsing exception in the
exception branch of _tool_calling by chaining it when raising or returning
ToolUsageError, while retaining the existing generic tool_arguments_error
message and raise_error behavior. Ensure callers can inspect the concrete cause
for malformed JSON, repair, or schema failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 58fa3175-1660-49ae-b1c4-68b4465c9c5b

📥 Commits

Reviewing files that changed from the base of the PR and between 2e95bfb and 813b853.

📒 Files selected for processing (3)
  • lib/crewai/src/crewai/experimental/agent_executor.py
  • lib/crewai/src/crewai/tools/tool_usage.py
  • lib/crewai/tests/agents/test_agent_executor.py

Comment on lines +2203 to +2224
elif func_name in self._available_functions:
# Fallback to sync function for tools without structured_tool
try:
tool_func = self._available_functions[func_name]
raw_result = tool_func(**args_dict)
raw_tool_result = raw_result

# Add to cache after successful execution (before string conversion)
if self.tools_handler and self.tools_handler.cache:
should_cache = True
if original_tool:
should_cache = original_tool.cache_function(
args_dict, raw_result
)
if should_cache:
self.tools_handler.cache.add(
tool=func_name, input=input_str, output=raw_result
)

result = format_native_tool_output_for_agent(
output_tool, raw_result
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Sync fallback blocks the event loop — wrap in asyncio.to_thread.

The removed ThreadPoolExecutor path let genuinely synchronous tools run off the loop. Calling tool_func(**args_dict) directly here blocks the running loop for the duration of the tool, which (a) serializes the asyncio.gather batch at Line 1743 so parallel native tool calls are no longer parallel, and (b) stalls every other coroutine on the agent's loop. The PR objective explicitly states thread-pool execution should be preserved for synchronous tools.

Note the existing parallel test (test_execute_native_tool_runs_parallel_for_multiple_calls) does not catch this: two 0.2s sleeps serialize to ~0.4s, still under the < 0.5 assertion. Tightening that bound would surface the regression.

🐛 Proposed fix
             elif func_name in self._available_functions:
-                # Fallback to sync function for tools without structured_tool
+                # Fallback for tools without a structured tool: run sync callables
+                # off the event loop, await coroutine functions natively.
                 try:
                     tool_func = self._available_functions[func_name]
-                    raw_result = tool_func(**args_dict)
+                    if inspect.iscoroutinefunction(tool_func):
+                        raw_result = await tool_func(**args_dict)
+                    else:
+                        raw_result = await asyncio.to_thread(
+                            functools.partial(tool_func, **args_dict)
+                        )
                     raw_tool_result = raw_result
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
elif func_name in self._available_functions:
# Fallback to sync function for tools without structured_tool
try:
tool_func = self._available_functions[func_name]
raw_result = tool_func(**args_dict)
raw_tool_result = raw_result
# Add to cache after successful execution (before string conversion)
if self.tools_handler and self.tools_handler.cache:
should_cache = True
if original_tool:
should_cache = original_tool.cache_function(
args_dict, raw_result
)
if should_cache:
self.tools_handler.cache.add(
tool=func_name, input=input_str, output=raw_result
)
result = format_native_tool_output_for_agent(
output_tool, raw_result
)
elif func_name in self._available_functions:
# Fallback for tools without a structured tool: run sync callables
# off the event loop, await coroutine functions natively.
try:
tool_func = self._available_functions[func_name]
if inspect.iscoroutinefunction(tool_func):
raw_result = await tool_func(**args_dict)
else:
raw_result = await asyncio.to_thread(
functools.partial(tool_func, **args_dict)
)
raw_tool_result = raw_result
# Add to cache after successful execution (before string conversion)
if self.tools_handler and self.tools_handler.cache:
should_cache = True
if original_tool:
should_cache = original_tool.cache_function(
args_dict, raw_result
)
if should_cache:
self.tools_handler.cache.add(
tool=func_name, input=input_str, output=raw_result
)
result = format_native_tool_output_for_agent(
output_tool, raw_result
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/experimental/agent_executor.py` around lines 2203 -
2224, Update the synchronous fallback in the native tool execution path
identified by _available_functions and tool_func so tool_func(**args_dict) runs
via asyncio.to_thread instead of directly on the event loop. Await the offloaded
call while preserving the existing raw-result, caching, and formatting flow for
synchronous tools.

… factory.py

- Line 199: Bedrock missing bucket config now raises PermanentUploadError
- Line 216: Unknown provider now raises PermanentUploadError
- All ImportError cases now raise PermanentUploadError with helpful messages
- Matches pattern from crewAIInc#6430 fix for consistent error handling
Copilot AI review requested due to automatic review settings July 28, 2026 10:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/crewai-files/src/crewai_files/uploaders/factory.py (1)

150-154: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not classify every ImportError in create_file_uploader as a missing SDK.

These try blocks cover both the uploader import and the constructor, so unrelated transitive import failures or constructor bugs in GeminiFileUploader, AnthropicFileUploader, OpenAIFileUploader, or BedrockFileUploader are logged as “package not installed” and converted into non-retryable PermanentUploadError. Catch only the expected dependency failure by name, or re-raise unexpected ImportError instances while preserving the original exception.

Also applies to lines 165-169, 185-187, and 214-216.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai-files/src/crewai_files/uploaders/factory.py` around lines 150 -
154, Update create_file_uploader’s provider-specific exception handling so only
the expected missing SDK dependency is converted to PermanentUploadError and
logged as not installed. Preserve the original exception and re-raise unrelated
ImportError instances from uploader imports or constructors for
GeminiFileUploader, AnthropicFileUploader, OpenAIFileUploader, and
BedrockFileUploader; apply the same behavior to all four try blocks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/crewai-files/src/crewai_files/uploaders/factory.py`:
- Around line 218-219: Update the get_uploader() docstring to state that
unsupported providers raise PermanentUploadError instead of returning None. Keep
the documented behavior for supported providers accurate and aligned with the
existing exception path.

---

Outside diff comments:
In `@lib/crewai-files/src/crewai_files/uploaders/factory.py`:
- Around line 150-154: Update create_file_uploader’s provider-specific exception
handling so only the expected missing SDK dependency is converted to
PermanentUploadError and logged as not installed. Preserve the original
exception and re-raise unrelated ImportError instances from uploader imports or
constructors for GeminiFileUploader, AnthropicFileUploader, OpenAIFileUploader,
and BedrockFileUploader; apply the same behavior to all four try blocks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a5442dce-6ad5-4dcb-87ca-f9f570d4c4bd

📥 Commits

Reviewing files that changed from the base of the PR and between 813b853 and 70c96f4.

📒 Files selected for processing (1)
  • lib/crewai-files/src/crewai_files/uploaders/factory.py

Comment on lines 218 to +219
logger.debug(f"No file uploader available for provider: {provider}")
raise
raise PermanentUploadError(f"No file uploader available for provider: {provider}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the get_uploader() API documentation.

The docstring says unsupported providers return None, but this path now raises PermanentUploadError. Document the exception behavior so callers do not rely on an invalid return contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai-files/src/crewai_files/uploaders/factory.py` around lines 218 -
219, Update the get_uploader() docstring to state that unsupported providers
raise PermanentUploadError instead of returning None. Keep the documented
behavior for supported providers accurate and aligned with the existing
exception path.

Source: Coding guidelines

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

Labels

None yet

Projects

None yet

2 participants