Repository navigation
Conversation
Strix Security ReviewWarning This pull request has 94 commits after the last Strix review ( 1 open security finding on this PR:
Review summaryReviewed all four changed files of the async-native-tool caller-loop fix, tracing the new bridge from Fixed the findings? re-run the review, or tag Updated for Reviewed by Strix |
| loop = caller_loop if caller_loop is not None else _caller_loop_context.get() | ||
| if loop is not None and tool is not None and _has_async_implementation(tool): | ||
| try: | ||
| return asyncio.run_coroutine_threadsafe(tool.arun(**args_dict), loop).result() | ||
| except RuntimeError: | ||
| pass # caller loop is already closed — run() fresh-loop is the fallback | ||
| return tool_func(**args_dict) |
There was a problem hiding this comment.
🟢 Async native tool is silently executed twice when its coroutine raises RuntimeError
Severity: LOW · CWE-754
Summary
The new helper run_native_tool_result wraps asyncio.run_coroutine_threadsafe(tool.arun(**args_dict), loop).result() in except RuntimeError: pass, then falls through to the legacy invocation tool_func(**args_dict). Because concurrent.futures.Future.result() re-raises whatever exception the coroutine raised, a RuntimeError raised by the tool itself is indistinguishable from "the caller's event loop is already closed". The handler therefore swallows the tool's exception and invokes the tool a second time through the legacy path, within a single logical tool call.
Consequence: duplicated side effect, masked failure
For any async native tool that raises RuntimeError after performing a side effect, the side effect happens twice while the caller only observes the second attempt:
- the genuine failure never reaches the call site's
except Exception,failure_from_exception(),task.increment_tools_errors(), the error event or the tool-failure policy, so failure accounting and alerting miss it; - when the retry succeeds, the agent sees success and the failure is silently masked;
current_usage_countis incremented once per attempt, somax_usage_countaccounting is wrong (two claims for one call).
Same block: unguarded re-entrancy and shared-loop starvation
Two further properties of the same unconditional bridge were measured on the patched tree:
- Re-entrancy: the helper never checks whether
loopis the loop already executing the current thread. Invoked on the caller loop's own thread,future.result()blocks that very loop, so the submission can never be serviced and the call never returns — the process must be killed, and even an outerasyncio.wait_fortimeout cannot fire because the loop itself is blocked. No code path in the framework was found that reaches the helper on the loop thread, so this is a latent hazard in a public helper rather than a demonstrated framework defect; it becomes reachable the moment any caller stops offloading tool execution to a worker thread. - Starvation of the application's loop: because the coroutine now runs on the loop the application awaits on, an async tool whose body performs blocking work stalls every other task on that loop for the tool's whole duration. Measured with a 10 ms heartbeat on the caller loop and a 1.0 s blocking tool body: maximum loop gap
1.001 sthrough the new bridge versus0.012 swhen the same tool runs the previous way on its private loop; end-to-end throughkickoff_async,1.005 sversus0.015 s. This is reachable with shipped tools — several class-based tools declared asasync def _arunsimply forward to their blocking synchronous implementation, and are now classified as async-capable and executed on the shared loop. No availability metric is claimed for the framework defect itself, because the stall requires a tool implementation that blocks inside a coroutine (a defect in the tool, and the remediation for it lives outside this change); it is recorded here so the operational consequence of the new bridge is visible.
Demonstrated
A dynamic repro against the patched tree runs an async native tool through the async kickoff path (kickoff_async → worker thread → run_native_tool_result) where the tool records a side effect and raises RuntimeError on its first attempt:
- tool body executed 2 times (attempt 1 on the kickoff caller's loop, attempt 2 on a fresh loop);
current_usage_count == 2for one call;- end-to-end
kickoff_asyncrun: 2 side effects recorded and the model saw the successful value — the call was reported as a success; - pre-patch baseline,
tool.run(...)called directly: 1 execution and theRuntimeErrorpropagates.
Exceptions other than RuntimeError, sync tools and sync-kickoff paths are unaffected, which bounds the defect to async native tools reached through the caller-loop bridge.
| loop = caller_loop if caller_loop is not None else _caller_loop_context.get() | |
| if loop is not None and tool is not None and _has_async_implementation(tool): | |
| try: | |
| return asyncio.run_coroutine_threadsafe(tool.arun(**args_dict), loop).result() | |
| except RuntimeError: | |
| pass # caller loop is already closed — run() fresh-loop is the fallback | |
| return tool_func(**args_dict) | |
| loop = caller_loop if caller_loop is not None else _caller_loop_context.get() | |
| if loop is not None and tool is not None and _has_async_implementation(tool): | |
| try: | |
| running_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop() | |
| except RuntimeError: | |
| running_loop = None | |
| # Bridging onto the loop that is already executing this call would block that | |
| # loop inside future.result() forever, so fall back to the sync path instead. | |
| if running_loop is not loop and not loop.is_closed(): | |
| coro = tool.arun(**args_dict) | |
| try: | |
| future = asyncio.run_coroutine_threadsafe(coro, loop) | |
| except RuntimeError: | |
| coro.close() # loop shut down mid-flight; use the run() fallback | |
| else: | |
| # Tool exceptions (RuntimeError included) must propagate: re-running | |
| # the tool here would repeat its side effects and hide the failure. | |
| return future.result() | |
| return tool_func(**args_dict) |
Prompt to fix with AI
This is a security vulnerability found during a code review.
Vulnerability: Async native tool is silently executed twice when its coroutine raises RuntimeError
Severity: LOW
CWE: CWE-754
## Summary
The new helper `run_native_tool_result` wraps `asyncio.run_coroutine_threadsafe(tool.arun(**args_dict), loop).result()` in `except RuntimeError: pass`, then falls through to the legacy invocation `tool_func(**args_dict)`. Because `concurrent.futures.Future.result()` re-raises whatever exception the coroutine raised, a `RuntimeError` raised **by the tool itself** is indistinguishable from "the caller's event loop is already closed". The handler therefore swallows the tool's exception and **invokes the tool a second time** through the legacy path, within a single logical tool call.
## Consequence: duplicated side effect, masked failure
For any async native tool that raises `RuntimeError` after performing a side effect, the side effect happens twice while the caller only observes the second attempt:
- the genuine failure never reaches the call site's `except Exception`, `failure_from_exception()`, `task.increment_tools_errors()`, the error event or the tool-failure policy, so failure accounting and alerting miss it;
- when the retry succeeds, the agent sees success and the failure is silently masked;
- `current_usage_count` is incremented once per attempt, so `max_usage_count` accounting is wrong (two claims for one call).
## Same block: unguarded re-entrancy and shared-loop starvation
Two further properties of the same unconditional bridge were measured on the patched tree:
- **Re-entrancy**: the helper never checks whether `loop` is the loop already executing the current thread. Invoked on the caller loop's own thread, `future.result()` blocks that very loop, so the submission can never be serviced and the call never returns — the process must be killed, and even an outer `asyncio.wait_for` timeout cannot fire because the loop itself is blocked. No code path in the framework was found that reaches the helper on the loop thread, so this is a latent hazard in a public helper rather than a demonstrated framework defect; it becomes reachable the moment any caller stops offloading tool execution to a worker thread.
- **Starvation of the application's loop**: because the coroutine now runs on the loop the application awaits on, an async tool whose body performs blocking work stalls every other task on that loop for the tool's whole duration. Measured with a 10 ms heartbeat on the caller loop and a 1.0 s blocking tool body: maximum loop gap `1.001 s` through the new bridge versus `0.012 s` when the same tool runs the previous way on its private loop; end-to-end through `kickoff_async`, `1.005 s` versus `0.015 s`. This is reachable with shipped tools — several class-based tools declared as `async def _arun` simply forward to their blocking synchronous implementation, and are now classified as async-capable and executed on the shared loop. No availability metric is claimed for the framework defect itself, because the stall requires a tool implementation that blocks inside a coroutine (a defect in the tool, and the remediation for it lives outside this change); it is recorded here so the operational consequence of the new bridge is visible.
## Demonstrated
A dynamic repro against the patched tree runs an async native tool through the async kickoff path (`kickoff_async` → worker thread → `run_native_tool_result`) where the tool records a side effect and raises `RuntimeError` on its first attempt:
- tool body executed **2 times** (attempt 1 on the kickoff caller's loop, attempt 2 on a fresh loop);
- `current_usage_count == 2` for one call;
- end-to-end `kickoff_async` run: **2 side effects** recorded and the model saw the successful value — the call was reported as a success;
- pre-patch baseline, `tool.run(...)` called directly: 1 execution and the `RuntimeError` propagates.
Exceptions other than `RuntimeError`, sync tools and sync-kickoff paths are unaffected, which bounds the defect to async native tools reached through the caller-loop bridge.
Location: lib/crewai/src/crewai/utilities/agent_utils.py:1814-1820
Context: Caller-loop bridge swallows the tool's own RuntimeError, re-invokes the tool, and never checks that the target loop is not the one already executing the call
```
// Before:
loop = caller_loop if caller_loop is not None else _caller_loop_context.get()
if loop is not None and tool is not None and _has_async_implementation(tool):
try:
return asyncio.run_coroutine_threadsafe(tool.arun(**args_dict), loop).result()
except RuntimeError:
pass # caller loop is already closed — run() fresh-loop is the fallback
return tool_func(**args_dict)
// After:
loop = caller_loop if caller_loop is not None else _caller_loop_context.get()
if loop is not None and tool is not None and _has_async_implementation(tool):
try:
running_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
except RuntimeError:
running_loop = None
# Bridging onto the loop that is already executing this call would block that
# loop inside future.result() forever, so fall back to the sync path instead.
if running_loop is not loop and not loop.is_closed():
coro = tool.arun(**args_dict)
try:
future = asyncio.run_coroutine_threadsafe(coro, loop)
except RuntimeError:
coro.close() # loop shut down mid-flight; use the run() fallback
else:
# Tool exceptions (RuntimeError included) must propagate: re-running
# the tool here would repeat its side effects and hide the failure.
return future.result()
return tool_func(**args_dict)
```
How to fix:
Distinguish "cannot schedule onto the caller loop" from "the tool failed", and never run the bridge on the loop that is already executing the call. Check `loop.is_closed()` and compare against the thread's currently running loop before scheduling, treat only a scheduling failure as the signal to fall back, and let every exception raised by the coroutine propagate unchanged out of the helper. Never re-invoke a tool after it has started executing: at that point its side effects are already in flight, so a second invocation converts one failure into a duplicated state change. When a fallback is required, close the unscheduled coroutine so it is not left un-awaited.
Separately, keep asynchronous tool implementations non-blocking (offload blocking client calls to a worker thread) so that executing them on the caller's loop does not stall unrelated work, and document that requirement for the new caller-loop affinity behaviour. Add a regression test asserting that an async native tool raising `RuntimeError` executes exactly once, that the error reaches the agent as a tool failure, and that the usage counter increments once.
Please fix this vulnerability. If you propose a fix, make it concise and minimal.React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.
39f754e to
a4f92c7
Compare
…754) Address the security review on crewAIInc#8354: Future.result() re-raises whatever the coroutine raised, so 'except RuntimeError' could not distinguish a closed caller loop (scheduling failure) from the tool itself raising RuntimeError. The handler then fell back to tool_func(**args) and silently executed the tool a second time: duplicated side effects, masked failure (the retry's success is what the agent sees), and a double usage claim (two _claim_usage() calls for one logical invocation). Only exceptions raised while *scheduling* the coroutine now trigger the fallback; exceptions from the coroutine propagate unchanged. Also guard re-entrancy: if the helper is invoked on the target loop's own thread, future.result() would block that loop forever (it can never service its own submission). Detect the running loop and use the sync callable instead, and close the unscheduled coroutine when scheduling fails so it is not left un-awaited. Document the non-blocking contract for async implementations: bridged coroutines share the application's loop, so a tool that blocks inside its coroutine stalls unrelated tasks (remediation is in the tool, not the bridge). Regression tests: a RuntimeError from an async tool executes exactly once, propagates, and claims usage once; the helper run on the target loop uses the sync fallback without scheduling (no deadlock). Also note the review's third point (loop starvation) is documented rather than 'fixed': abandoning loop affinity would reintroduce the affinity regression this PR exists for; tools must not block in coroutines.
|
Fixed in Change (helper only, same 3-branch shape you suggested):
Regression tests added (
Verification: 6/6 tests in the file green, |
|
Good catch — that one's on me. The |
8bb5045 to
cc6bfa3
Compare
…754) Address the security review on crewAIInc#8354: Future.result() re-raises whatever the coroutine raised, so 'except RuntimeError' could not distinguish a closed caller loop (scheduling failure) from the tool itself raising RuntimeError. The handler then fell back to tool_func(**args) and silently executed the tool a second time: duplicated side effects, masked failure (the retry's success is what the agent sees), and a double usage claim (two _claim_usage() calls for one logical invocation). Only exceptions raised while *scheduling* the coroutine now trigger the fallback; exceptions from the coroutine propagate unchanged. Also guard re-entrancy: if the helper is invoked on the target loop's own thread, future.result() would block that loop forever (it can never service its own submission). Detect the running loop and use the sync callable instead, and close the unscheduled coroutine when scheduling fails so it is not left un-awaited. Document the non-blocking contract for async implementations: bridged coroutines share the application's loop, so a tool that blocks inside its coroutine stalls unrelated tasks (remediation is in the tool, not the bridge). Regression tests: a RuntimeError from an async tool executes exactly once, propagates, and claims usage once; the helper run on the target loop uses the sync fallback without scheduling (no deadlock). Also note the review's third point (loop starvation) is documented rather than 'fixed': abandoning loop affinity would reintroduce the affinity regression this PR exists for; tools must not block in coroutines.
…754) Address the security review on crewAIInc#8354: Future.result() re-raises whatever the coroutine raised, so 'except RuntimeError' could not distinguish a closed caller loop (scheduling failure) from the tool itself raising RuntimeError. The handler then fell back to tool_func(**args) and silently executed the tool a second time: duplicated side effects, masked failure (the retry's success is what the agent sees), and a double usage claim (two _claim_usage() calls for one logical invocation). Only exceptions raised while *scheduling* the coroutine now trigger the fallback; exceptions from the coroutine propagate unchanged. Also guard re-entrancy: if the helper is invoked on the target loop's own thread, future.result() would block that loop forever (it can never service its own submission). Detect the running loop and use the sync callable instead, and close the unscheduled coroutine when scheduling fails so it is not left un-awaited. Document the non-blocking contract for async implementations: bridged coroutines share the application's loop, so a tool that blocks inside its coroutine stalls unrelated tasks (remediation is in the tool, not the bridge). Regression tests: a RuntimeError from an async tool executes exactly once, propagates, and claims usage once; the helper run on the target loop uses the sync fallback without scheduling (no deadlock). Also note the review's third point (loop starvation) is documented rather than 'fixed': abandoning loop affinity would reintroduce the affinity regression this PR exists for; tools must not block in coroutines.
cc6bfa3 to
773a81c
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 773a81c. Configure here.
|
|
||
| return streaming_output | ||
|
|
||
| return await asyncio.to_thread(self.kickoff, inputs, input_files) |
There was a problem hiding this comment.
Streaming kickoff skips caller loop
Medium Severity
The streaming branch of kickoff_async still offloads via asyncio.to_thread(self.kickoff) without capturing the caller loop in _caller_loop_context. Async native tools on that path keep running on a fresh loop, so caller-loop-bound resources fail with the same cross-loop RuntimeError this change aims to fix.
Reviewed by Cursor Bugbot for commit 773a81c. Configure here.
| func = getattr(meth, "__func__", meth) | ||
| # The BaseTool default raises NotImplementedError on await; a real override | ||
| # denotes class-based async support. | ||
| return func is not getattr(BaseTool, "_arun", None) |
There was a problem hiding this comment.
Inherited async tools not detected
Medium Severity
_has_async_implementation only inspects type(tool).__dict__ for _arun, so a class-based tool that inherits its async implementation from a parent or mixin is treated as sync. Those tools skip the caller-loop bridge and still run through a fresh asyncio.run loop.
Reviewed by Cursor Bugbot for commit 773a81c. Configure here.
773a81c to
939884c
Compare
…754) Address the security review on crewAIInc#8354: Future.result() re-raises whatever the coroutine raised, so 'except RuntimeError' could not distinguish a closed caller loop (scheduling failure) from the tool itself raising RuntimeError. The handler then fell back to tool_func(**args) and silently executed the tool a second time: duplicated side effects, masked failure (the retry's success is what the agent sees), and a double usage claim (two _claim_usage() calls for one logical invocation). Only exceptions raised while *scheduling* the coroutine now trigger the fallback; exceptions from the coroutine propagate unchanged. Also guard re-entrancy: if the helper is invoked on the target loop's own thread, future.result() would block that loop forever (it can never service its own submission). Detect the running loop and use the sync callable instead, and close the unscheduled coroutine when scheduling fails so it is not left un-awaited. Document the non-blocking contract for async implementations: bridged coroutines share the application's loop, so a tool that blocks inside its coroutine stalls unrelated tasks (remediation is in the tool, not the bridge). Regression tests: a RuntimeError from an async tool executes exactly once, propagates, and claims usage once; the helper run on the target loop uses the sync fallback without scheduling (no deadlock). Also note the review's third point (loop starvation) is documented rather than 'fixed': abandoning loop affinity would reintroduce the affinity regression this PR exists for; tools must not block in coroutines.
|
Rebased onto current
The fork-workflow runs need maintainer approval again after the force-push — re-requesting when convenient. 🙏 |
…754) Address the security review on crewAIInc#8354: Future.result() re-raises whatever the coroutine raised, so 'except RuntimeError' could not distinguish a closed caller loop (scheduling failure) from the tool itself raising RuntimeError. The handler then fell back to tool_func(**args) and silently executed the tool a second time: duplicated side effects, masked failure (the retry's success is what the agent sees), and a double usage claim (two _claim_usage() calls for one logical invocation). Only exceptions raised while *scheduling* the coroutine now trigger the fallback; exceptions from the coroutine propagate unchanged. Also guard re-entrancy: if the helper is invoked on the target loop's own thread, future.result() would block that loop forever (it can never service its own submission). Detect the running loop and use the sync callable instead, and close the unscheduled coroutine when scheduling fails so it is not left un-awaited. Document the non-blocking contract for async implementations: bridged coroutines share the application's loop, so a tool that blocks inside its coroutine stalls unrelated tasks (remediation is in the tool, not the bridge). Regression tests: a RuntimeError from an async tool executes exactly once, propagates, and claims usage once; the helper run on the target loop uses the sync fallback without scheduling (no deadlock). Also note the review's third point (loop starvation) is documented rather than 'fixed': abandoning loop affinity would reintroduce the affinity regression this PR exists for; tools must not block in coroutines.
939884c to
78fce92
Compare
The post-crewAIInc#8192 executor runs every sync Flow method in a worker thread (asyncio.to_thread), so an async native tool's coroutine went through the bare asyncio.run inside Tool.run on a fresh, unrelated loop per call. Anything bound to the caller's loop then fails with 'attached to a different loop' — a caller-loop Future, an httpx.AsyncClient created before kickoff, sockets. Verified end to end on upstream main: an async native tool awaiting a Future created on the kickoff_async caller's loop raises 'RuntimeError: Task ... got Future ... attached to a different loop' (the post-crewAIInc#8192 shape of the crewAIInc#7630 family). Fix: async kickoffs capture the caller's event loop into a ContextVar (Crew.kickoff_async, Crew.akickoff via the executor, AgentExecutor .invoke_async). ContextVars propagate through asyncio.to_thread and the runtime's context-copying, so every sync lane that executes a native tool call can reach it. run_native_tool_result then bridges async-capable tools onto the caller loop via tool.arun and blocks the worker thread until the coroutine finishes; sync tools and sync callers keep the exact historical tool_func behavior, including the available-functions mapping (test doubles stay authoritative). Async-capability detection: Tool-wrapped functions are async iff the wrapped function is a coroutine function (Tool always overrides _arun, even for sync funcs); class-based BaseTools qualify when their type overrides the NotImplementedError-raising BaseTool._arun default. Regression tests in tests/agents/: the worker-thread bridge to a caller-loop Future (unit) and the full Crew + scripted-LLM kickoff_async path (integration, asserts the caller-loop Future resolves instead of the cross-loop error).
…754) Address the security review on crewAIInc#8354: Future.result() re-raises whatever the coroutine raised, so 'except RuntimeError' could not distinguish a closed caller loop (scheduling failure) from the tool itself raising RuntimeError. The handler then fell back to tool_func(**args) and silently executed the tool a second time: duplicated side effects, masked failure (the retry's success is what the agent sees), and a double usage claim (two _claim_usage() calls for one logical invocation). Only exceptions raised while *scheduling* the coroutine now trigger the fallback; exceptions from the coroutine propagate unchanged. Also guard re-entrancy: if the helper is invoked on the target loop's own thread, future.result() would block that loop forever (it can never service its own submission). Detect the running loop and use the sync callable instead, and close the unscheduled coroutine when scheduling fails so it is not left un-awaited. Document the non-blocking contract for async implementations: bridged coroutines share the application's loop, so a tool that blocks inside its coroutine stalls unrelated tasks (remediation is in the tool, not the bridge). Regression tests: a RuntimeError from an async tool executes exactly once, propagates, and claims usage once; the helper run on the target loop uses the sync fallback without scheduling (no deadlock). Also note the review's third point (loop starvation) is documented rather than 'fixed': abandoning loop affinity would reintroduce the affinity regression this PR exists for; tools must not block in coroutines.
78fce92 to
f1afd0d
Compare
|
Thanks for the pull request. First-time contributors need an associated open issue before we can review a PR.
See the contributing guide. |
…754) Address the security review on crewAIInc#8354: Future.result() re-raises whatever the coroutine raised, so 'except RuntimeError' could not distinguish a closed caller loop (scheduling failure) from the tool itself raising RuntimeError. The handler then fell back to tool_func(**args) and silently executed the tool a second time: duplicated side effects, masked failure (the retry's success is what the agent sees), and a double usage claim (two _claim_usage() calls for one logical invocation). Only exceptions raised while *scheduling* the coroutine now trigger the fallback; exceptions from the coroutine propagate unchanged. Also guard re-entrancy: if the helper is invoked on the target loop's own thread, future.result() would block that loop forever (it can never service its own submission). Detect the running loop and use the sync callable instead, and close the unscheduled coroutine when scheduling fails so it is not left un-awaited. Document the non-blocking contract for async implementations: bridged coroutines share the application's loop, so a tool that blocks inside its coroutine stalls unrelated tasks (remediation is in the tool, not the bridge). Regression tests: a RuntimeError from an async tool executes exactly once, propagates, and claims usage once; the helper run on the target loop uses the sync fallback without scheduling (no deadlock). Also note the review's third point (loop starvation) is documented rather than 'fixed': abandoning loop affinity would reintroduce the affinity regression this PR exists for; tools must not block in coroutines.
…754) Address the security review on crewAIInc#8354: Future.result() re-raises whatever the coroutine raised, so 'except RuntimeError' could not distinguish a closed caller loop (scheduling failure) from the tool itself raising RuntimeError. The handler then fell back to tool_func(**args) and silently executed the tool a second time: duplicated side effects, masked failure (the retry's success is what the agent sees), and a double usage claim (two _claim_usage() calls for one logical invocation). Only exceptions raised while *scheduling* the coroutine now trigger the fallback; exceptions from the coroutine propagate unchanged. Also guard re-entrancy: if the helper is invoked on the target loop's own thread, future.result() would block that loop forever (it can never service its own submission). Detect the running loop and use the sync callable instead, and close the unscheduled coroutine when scheduling fails so it is not left un-awaited. Document the non-blocking contract for async implementations: bridged coroutines share the application's loop, so a tool that blocks inside its coroutine stalls unrelated tasks (remediation is in the tool, not the bridge). Regression tests: a RuntimeError from an async tool executes exactly once, propagates, and claims usage once; the helper run on the target loop uses the sync fallback without scheduling (no deadlock). Also note the review's third point (loop starvation) is documented rather than 'fixed': abandoning loop affinity would reintroduce the affinity regression this PR exists for; tools must not block in coroutines.


Fixes #8904.
Follow-up to #7630 (the closed #7634 was the pre-#8192 fix for the same family).
The bug (reproduced on
main)After #8192, every sync Flow method runs in a worker thread (
asyncio.to_thread, flow runtime_execute_method). An async native tool's coroutine therefore goes through the bareasyncio.run()insideTool.run— on a fresh, unrelated event loop per call. Anything bound to the kickoff caller's loop fails deterministically:Minimal repro on unpatched
main: a Crew with one@tool async def; the tool awaits aFuturecreated on thekickoff_asynccaller's loop afterasyncio.run(crew.kickoff_async())→ the tool result the LLM sees isError executing tool: ... attached to a different loop. Same shape for any caller-loop-bound resource (httpx.AsyncClientcreated before kickoff, sockets).The fix
Crew.kickoff_asyncnon-stream branch,AgentExecutor.invoke_async).Crew.kickoff_asyncisawait asyncio.to_thread(self.kickoff, ...), so capturing before the thread boundary is what makes the loop visible inside every worker lane — ContextVars propagate throughto_threadand the Flow runtime'scopy_context(including theThreadPoolExecutorinexecute_native_tool).agent_utils.run_native_tool_result(...)executes the tool: when a caller loop is present and the tool has a working async implementation, bridgetool.arun(**args)onto the caller loop viarun_coroutine_threadsafe(...).result()(the worker thread blocks; the coroutine runs with caller-loop affinity). Otherwise the exact historical sync behavior — theavailable_functionsmapping stays authoritative, so test doubles and cache wrappers are untouched.Tool-wrapped functions are async iff the wrapped function is a coroutine function (Toolalways overrides_arun, even for sync funcs); class-basedBaseTools qualify when their type overrides theNotImplementedError-raisingBaseTool._arun.Tests
New
lib/crewai/tests/agents/test_native_async_tool_loop_affinity.py:Future(unit, with and without caller loop),Crew+ scripted-LLMkickoff_asyncpath asserting the caller-loop Future resolves instead of the cross-loop error.Verification
ruff checkclean on all touched files.main:pytest lib/crewai/tests/agents -k "native or agent_executor or invoke"— identical failure sets (3 pre-existingTestDeferredNativeToolResults/TestResponseFormatWithKickofffailures reproduce on pristinemainin this environment; 172 pass on both), plus 4 new green tests.got:expected-iteminstead of the error.Note
Medium Risk
Changes core tool execution and cross-thread asyncio bridging on every async kickoff; fallbacks and tests limit blast radius but blocking
future.result()in worker threads can stall the caller loop if async tools block.Overview
Fixes "attached to a different loop" failures when async native tools run under
kickoff_async/invoke_async, where sync Flow work executes inasyncio.to_threadworker threads and tools previously ran via a fresh loop insideTool.run.run_native_tool_resultinagent_utilscaptures the caller loop in a ContextVar at async entry points (Crew.kickoff_async,AgentExecutor.invoke_async) and, for tools with a real async implementation, schedulestool.arunon that loop withrun_coroutine_threadsafe. Sync tools, missing caller loop, deadlock-prone cases (already on target loop), and closed loops keep the priortool_func(**args)path.Native tool execution in
agent_executorand sharedagent_utilshelpers now routes through this helper instead of calling the mapped function directly. Adds regression tests intest_native_async_tool_loop_affinity.py(unit bridge, error propagation, E2Ekickoff_async).Reviewed by Cursor Bugbot for commit f1afd0d. Bugbot is set up for automated code reviews on this repo. Configure here.