Repository navigation
Conversation
Strix Security ReviewWarning This pull request has 5 commits after the last Strix review ( To fix before merging (1)
PR summary: This PR makes async native tools run on the event loop that called the async kickoff: a ContextVar captures the caller loop in `Crew.kickoff_async` and `AgentExecutor.invoke_async`, and a new `run_native_tool_result` helper bridges `tool.arun(...)` onto that loop with `run_coroutine_threadsafe(...).result()` while the worker thread waits, keeping the old direct call for sync tools. Review summaryReviewed the full diff and traced the new loop-bridging path through the crew entrypoints, Ruled out (8)
Fixed the findings? re-run the review, or tag Review 1 on this PR · Reviewed by Strix |
|
Thanks @strix-security and Cursor Bugbot — all three findings were real and are fixed in 1. 🔴 HIGH CWE-833 — unbounded bridge wait ( The root cause is a documentation bug as much as a code one: the previous docstring recommended 2. 🟡 Medium — inherited 3. 🟡 Medium — streaming kickoff skipped the caller loop Three regression tests were added — each fails on the previous head and passes now:
@strix-security — re-review requested, the HIGH should be closed by the bounded wait. |
| "blocking work to the loop's default executor, which the pending " | ||
| "kickoffs are occupying; make the tool await its library's async client " | ||
| "or use a dedicated ThreadPoolExecutor (see crewai#8904)." | ||
| ) from None |
There was a problem hiding this comment.
Bridge timeout swallows tool TimeoutError
Medium Severity
The new bridge wait handler treats every TimeoutError as a hung run_coroutine_threadsafe wait. On Python 3.11+ concurrent.futures.TimeoutError is the builtin TimeoutError, so a finished tool that raised asyncio.TimeoutError (for example from asyncio.wait_for) is rewritten into the 300s hung-loop error and the original exception is discarded.
Reviewed by Cursor Bugbot for commit f2e0824. Configure here.
|
Structural fix for the CWE-833 finding ( The deadlock was: each in-flight
The bounded wait stays as defence in depth: lanes we don't own can still occupy default-pool threads (a Flow sync method wrapping a nested kickoff does exactly that), so a starve there surfaces as Two more regression tests, both red before this commit:
Current head: loop-affinity suite 11/11, |
| # it inside future.result(); a closed loop cannot service the submission. In | ||
| # both cases the bridge is impossible, so use the historical sync path. | ||
| if running_loop is not loop and not loop.is_closed(): | ||
| coro = tool.arun(**args_dict) |
There was a problem hiding this comment.
🔒 Agentic Security Review
Severity: MEDIUM
Description: kickoff_async now pins every class-based native tool that overrides _arun onto the caller event loop. _has_async_implementation treats any _arun other than BaseTool._arun as async, so tools whose _arun only calls blocking _run (ExecuteCommandTool, ExecuteCodeTool, and the other Bedrock code-interpreter tools) run that sync body on the loop thread. NATIVE_TOOL_BRIDGE_TIMEOUT_SECONDS only unblocks the kickoff worker via future.cancel(); cancellation is not delivered until the synchronous section returns, so the tool's side effect continues and the loop stays stalled for the full call.
Impact: A model-selected native tool call during Crew.kickoff_async is supposed to keep blocking work on the kickoff worker and, if bridged, to stop at the bridge timeout. This change moves blocking _arun wrappers onto the shared caller loop. One long code-interpreter invoke (or any similar _arun that blocks before its next await) freezes every other task on that loop — other in-flight kickoff_async runs, streaming chunk delivery, and cancellation — for the whole sync call, which can exceed the 300s agent-visible timeout. The worker is told the tool timed out and can request another call, but that call only queues behind the still-running body, so the first command or code execution is not stopped. Previously tool.run executed _run on the kickoff worker and did not occupy the caller loop.
Remediation: Do not treat an _arun override as proof the body is non-blocking. Bridge tool.arun onto the caller loop only when the implementation actually awaits; run sync _run and blocking _arun wrappers via the existing tool.run path on the kickoff worker or a dedicated executor. A bridge timeout must bound the loop-side work itself — future.cancel() does not interrupt a synchronous section already running on the loop thread. Add a regression where async def _arun calls a blocking _run under kickoff_async and assert the caller loop still makes progress and the sync body is not left running after TimeoutError.
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.
…eaming kickoff Review follow-ups (Strix CWE-833 + Cursor Bugbot): - run_native_tool_result waited on the bridged coroutine with an unbounded future.result(). Crew.kickoff_async parks one thread of the caller loop's default executor per in-flight kickoff, so a tool that offloads blocking work with asyncio.to_thread needs a pool thread that the pending kickoffs are holding; with as many kickoffs as pool workers everything wedges forever. The wait is now bounded by NATIVE_TOOL_BRIDGE_TIMEOUT_SECONDS (default 300s, CREWAI_NATIVE_TOOL_BRIDGE_TIMEOUT override). On timeout the coroutine is cancelled and a TimeoutError explains the cause; it never falls back to tool_func, because the tool already started and may have had side effects. The docstring now warns against asyncio.to_thread there and points at a dedicated executor instead. - _has_async_implementation read _arun from the concrete class __dict__, so a class-based tool inheriting a real _arun from a parent was treated as sync and sent back to a fresh asyncio.run loop. Resolve through the MRO instead. - kickoff_async(stream=True) offloads self.kickoff in run_crew without setting the caller-loop context, so async native tools kept breaking on the streaming path. Capture/reset the loop there exactly as the non-streaming path does. New tests (each fails without its fix): inherited _arun bridging, bounded wait with cancellation, and streaming kickoff context capture. 9/9 pass; ruff check and format clean; mypy clean on both source files.
…an't starve the loop Structural follow-up to the bounded bridge wait (@strix-security's CWE-833 finding): a timeout makes the deadlock recoverable, but the deadlock itself is that Crew.kickoff_async parked one *default executor* thread per in-flight kickoff, while a bridged tool that offloads blocking work with asyncio.to_thread needs a thread from that same pool. With as many kickoffs as pool workers, every run wedged and all later to_thread work stalled. run_in_kickoff_thread keeps the asyncio.to_thread contract (off-loop, context copy so the caller-loop ContextVar and other task context propagate) but takes its worker from a dedicated bounded pool (_kickoff_executor, named crewai-kickoff). The loop's default pool is then free for the tool coroutines that need it. The bounded wait stays as defence in depth for lanes we do not own (e.g. a Flow sync method already occupying a default-pool thread). New tests: - test_concurrent_kickoffs_do_not_starve_the_loop_default_executor: runs min(32, cpu+4) concurrent kickoffs whose tools call asyncio.to_thread, with the bridge bound shortened to 5s so a starve fails fast instead of hanging. Red before this commit (tools returned "did not finish within 5s"), green after. - test_kickoff_worker_thread_is_not_a_loop_default_executor_thread: asserts the kickoff body runs on a crewai-kickoff thread, not asyncio_N. 11/11 loop-affinity tests pass, plus test_streaming.py 37/37; ruff check/format and mypy clean. test_crew.py::test_crew_kickoff_streaming_usage_metrics and the test_empty_llm_response_retry set fail identically on pristine main (no LLM credentials in this environment).
afce3f8 to
d874b95
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d874b95. Configure here.
| finally: | ||
| self._cleanup_deferred_tool_results() | ||
| self._is_executing = False | ||
| _reset_caller_loop(caller_loop_token) |
There was a problem hiding this comment.
Default executor starves on invoke_async
High Severity
invoke_async captures the caller loop so tools bridge via run_native_tool_result, but sync Flow methods still run on the loop default executor. Each run parks a default-pool thread in future.result(), so concurrent Agent.kickoff_async or Flow executions deadlock when a bridged tool also uses asyncio.to_thread. Crew.kickoff_async avoids this with run_in_kickoff_thread.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit d874b95. Configure here.


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 async kickoff and native-tool concurrency (ContextVar bridging, thread pools, blocking waits); behavior is scoped and heavily tested but affects all async kickoff + async tool combinations.
Overview
Fixes async native tools breaking under
kickoff_asyncwhen sync Flow work runs in worker threads: coroutines no longer spin up a fresh loop per call, so caller-loop-bound resources (e.g.httpx.AsyncClient,Futures) stop failing with attached to a different loop.Caller-loop capture:
Crew.kickoff_async(streaming and non-streaming) andAgentExecutor.invoke_asyncstore the running loop in a ContextVar for the duration of the run, so worker-thread tool paths can see it afterto_thread/ context copy.Tool execution: Native tool dispatch in
agent_executorandagent_utilsnow goes throughrun_native_tool_result, which schedulestool.arunon that loop viarun_coroutine_threadsafewhen the tool is async; sync tools and missing-loop cases keep the oldrun()behavior. Bridging skips when already on the target loop (deadlock guard), propagates tool errors without double execution, and times out afterCREWAI_NATIVE_TOOL_BRIDGE_TIMEOUT(default 300s) with coroutine cancellation.Executor pool split: Sync
kickoffbodies move from the loop’s defaultasyncio.to_threadpool to a dedicatedrun_in_kickoff_threadpool so kickoff threads waiting on bridged tools don’t starve the default pool when tools useasyncio.to_threadinside their coroutines.Regression coverage is added in
test_native_async_tool_loop_affinity.py(bridge behavior, E2Ekickoff_async, streaming path, concurrent kickoffs, inherited_arun).Reviewed by Cursor Bugbot for commit d874b95. Bugbot is set up for automated code reviews on this repo. Configure here.