Compare commits

...

5 Commits

Author SHA1 Message Date
Devin AI
e18477235b style: fix ruff formatting in browser_toolkit.py
Co-Authored-By: João <joao@crewai.com>
2026-03-26 14:00:52 +00:00
Devin AI
a4890e5626 fix: add Python 3.14 compatibility by replacing asyncio.get_event_loop() with get_running_loop()
Python 3.14 changed asyncio.get_event_loop() to raise RuntimeError when
no running event loop exists instead of silently creating one. This broke
all async code paths that relied on get_event_loop().

Changes:
- Replace asyncio.get_event_loop() with asyncio.get_running_loop() in all
  async contexts across crewai core and crewai-tools
- Update requires-python from '<3.14' to '<3.15' in all pyproject.toml files
- Add comprehensive tests for Python 3.14 async compatibility
- Regenerate uv.lock for the updated version constraint

Closes #5109

Co-Authored-By: João <joao@crewai.com>
2026-03-26 13:57:11 +00:00
João Moura
6193e082e1 docs: update changelog and version for v1.12.2 (#5103)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
2026-03-26 03:54:26 -03:00
João Moura
33f33c6fcc feat: bump versions to 1.12.2 (#5101) 2026-03-26 03:33:10 -03:00
alex-clawd
74976b157d fix: preserve method return value as flow output for @human_feedback with emit (#5099)
* fix: preserve method return value as flow output for @human_feedback with emit

When a @human_feedback decorated method with emit= is the final method in a
flow (no downstream listeners triggered), the flow's final output was
incorrectly set to the collapsed outcome string (e.g., 'approved') instead
of the method's actual return value (e.g., a state dict).

Root cause: _process_feedback() returns the collapsed_outcome string when
emit is set, and this string was being stored as the method's result in
_method_outputs.

The fix:
1. In human_feedback.py: After _process_feedback, stash the real method_output
   on the flow instance as _human_feedback_method_output when emit is set.

2. In flow.py: After appending a method result to _method_outputs, check if
   _human_feedback_method_output is set. If so, replace the last entry with
   the stashed real output and clear the stash.

This ensures:
- Routing still works correctly (collapsed outcome used for @listen matching)
- The flow's final result is the actual method return value
- If downstream listeners execute, their results become the final output

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: ruff format flow.py

* fix: use per-method dict stash for concurrency safety and None returns

Addresses review comments:
- Replace single flow-level slot with dict keyed by method name,
  safe under concurrent @human_feedback+emit execution
- Dict key presence (not value) indicates stashed output,
  correctly preserving None return values
- Added test for None return value preservation

---------

Co-authored-by: Joao Moura <joao@crewai.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 03:28:17 -03:00
30 changed files with 7047 additions and 4505 deletions

View File

@@ -4,6 +4,29 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="25 مارس 2026">
## v1.12.2
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## ما الذي تغير
### الميزات
- إضافة مرحلة إصدار المؤسسات إلى إصدار أدوات المطورين
### إصلاحات الأخطاء
- الحفاظ على قيمة إرجاع الطريقة كإخراج تدفق لـ @human_feedback مع emit
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.12.1
- مراجعة سياسة الأمان وتعليمات الإبلاغ
## المساهمون
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="25 مارس 2026">
## v1.12.1

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,29 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="Mar 25, 2026">
## v1.12.2
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## What's Changed
### Features
- Add enterprise release phase to devtools release
### Bug Fixes
- Preserve method return value as flow output for @human_feedback with emit
### Documentation
- Update changelog and version for v1.12.1
- Revise security policy and reporting instructions
## Contributors
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="Mar 25, 2026">
## v1.12.1

View File

@@ -4,6 +4,29 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 3월 25일">
## v1.12.2
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## 변경 사항
### 기능
- devtools 릴리스에 기업 릴리스 단계 추가
### 버그 수정
- @human_feedback과 함께 emit을 사용할 때 메서드 반환 값을 흐름 출력으로 유지
### 문서
- v1.12.1에 대한 변경 로그 및 버전 업데이트
- 보안 정책 및 보고 지침 수정
## 기여자
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="2026년 3월 25일">
## v1.12.1

View File

@@ -4,6 +4,29 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="25 mar 2026">
## v1.12.2
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## O que Mudou
### Recursos
- Adicionar fase de lançamento empresarial ao lançamento do devtools
### Correções de Bugs
- Preservar o valor de retorno do método como saída de fluxo para @human_feedback com emit
### Documentação
- Atualizar changelog e versão para v1.12.1
- Revisar política de segurança e instruções de relatório
## Contributors
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="25 mar 2026">
## v1.12.1

View File

@@ -6,7 +6,7 @@ readme = "README.md"
authors = [
{ name = "Greyson LaLonde", email = "greyson@crewai.com" }
]
requires-python = ">=3.10, <3.14"
requires-python = ">=3.10, <3.15"
dependencies = [
"Pillow~=12.1.1",
"pypdf~=6.9.1",

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.12.1"
__version__ = "1.12.2"

View File

@@ -6,12 +6,12 @@ readme = "README.md"
authors = [
{ name = "João Moura", email = "joaomdmoura@gmail.com" },
]
requires-python = ">=3.10, <3.14"
requires-python = ">=3.10, <3.15"
dependencies = [
"pytube~=15.0.0",
"requests~=2.32.5",
"docker~=7.1.0",
"crewai==1.12.1",
"crewai==1.12.2",
"tiktoken~=0.8.0",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

@@ -309,4 +309,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.12.1"
__version__ = "1.12.2"

View File

@@ -47,7 +47,7 @@ class BrowserSessionManager:
Returns:
An async browser instance specific to the thread
"""
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
while True:
with self._lock:
if thread_id in self._async_sessions:

View File

@@ -94,11 +94,9 @@ class BrowserBaseTool(BaseTool):
try:
import nest_asyncio # type: ignore[import-untyped]
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
nest_asyncio.apply(loop)
result: str = asyncio.get_event_loop().run_until_complete(
self._arun(*args, **kwargs)
)
result: str = loop.run_until_complete(self._arun(*args, **kwargs))
return result
except Exception as e:
return f"Error in patched _run: {e!s}"
@@ -118,7 +116,7 @@ class BrowserBaseTool(BaseTool):
def _is_in_asyncio_loop(self) -> bool:
"""Check if we're currently in an asyncio event loop."""
try:
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
return loop.is_running()
except RuntimeError:
return False
@@ -544,14 +542,13 @@ class BrowserToolkit:
def _nest_current_loop(self) -> None:
"""Apply nest_asyncio if we're in an asyncio loop."""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
try:
import nest_asyncio
loop = asyncio.get_running_loop()
try:
import nest_asyncio
nest_asyncio.apply(loop)
except Exception as e:
logger.warning(f"Failed to apply nest_asyncio: {e!s}")
nest_asyncio.apply(loop)
except Exception as e:
logger.warning(f"Failed to apply nest_asyncio: {e!s}")
except RuntimeError:
pass

View File

@@ -168,7 +168,7 @@ class SnowflakeSearchTool(BaseTool):
with self._pool_lock:
if self._connection_pool:
return self._connection_pool.pop()
return await asyncio.get_event_loop().run_in_executor(
return await asyncio.get_running_loop().run_in_executor(
self._thread_pool, self._create_connection
)

View File

@@ -6,7 +6,7 @@ readme = "README.md"
authors = [
{ name = "Joao Moura", email = "joao@crewai.com" }
]
requires-python = ">=3.10, <3.14"
requires-python = ">=3.10, <3.15"
dependencies = [
# Core Dependencies
"pydantic~=2.11.9",
@@ -54,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.12.1",
"crewai-tools==1.12.2",
]
embeddings = [
"tiktoken~=0.8.0"

View File

@@ -42,7 +42,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.12.1"
__version__ = "1.12.2"
_telemetry_submitted = False

View File

@@ -362,7 +362,7 @@ class MemoryTUI(App[None]):
panel.loading = True
try:
scope = self._selected_scope if self._selected_scope != "/" else None
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
matches = await loop.run_in_executor(
None,
lambda: self._memory.recall(query, scope=scope, limit=10, depth="deep"),

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.12.1"
"crewai[tools]==1.12.2"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.12.1"
"crewai[tools]==1.12.2"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.12.1"
"crewai[tools]==1.12.2"
]
[tool.crewai]

View File

@@ -883,6 +883,9 @@ class Flow(Generic[T], metaclass=FlowMeta):
self.human_feedback_history: list[HumanFeedbackResult] = []
self.last_human_feedback: HumanFeedbackResult | None = None
self._pending_feedback_context: PendingFeedbackContext | None = None
# Per-method stash for real @human_feedback output (keyed by method name)
# Used to decouple routing outcome from method return value when emit is set
self._human_feedback_method_outputs: dict[str, Any] = {}
self.suppress_flow_events: bool = suppress_flow_events
# User input history (for self.ask())
@@ -2290,6 +2293,17 @@ class Flow(Generic[T], metaclass=FlowMeta):
result = await result
self._method_outputs.append(result)
# For @human_feedback methods with emit, the result is the collapsed outcome
# (e.g., "approved") used for routing. But we want the actual method output
# to be the stored result (for final flow output). Replace the last entry
# if a stashed output exists. Dict-based stash is concurrency-safe and
# handles None return values (presence in dict = stashed, not value).
if method_name in self._human_feedback_method_outputs:
self._method_outputs[-1] = self._human_feedback_method_outputs.pop(
method_name
)
self._method_execution_counts[method_name] = (
self._method_execution_counts.get(method_name, 0) + 1
)

View File

@@ -591,6 +591,13 @@ def human_feedback(
):
_distill_and_store_lessons(self, method_output, raw_feedback)
# Stash the real method output for final flow result when emit is set
# (result is the collapsed outcome string for routing, but we want to
# preserve the actual method output as the flow's final result)
# Uses per-method dict for concurrency safety and to handle None returns
if emit:
self._human_feedback_method_outputs[func.__name__] = method_output
return result
wrapper: Any = async_wrapper
@@ -615,6 +622,13 @@ def human_feedback(
):
_distill_and_store_lessons(self, method_output, raw_feedback)
# Stash the real method output for final flow result when emit is set
# (result is the collapsed outcome string for routing, but we want to
# preserve the actual method output as the flow's final result)
# Uses per-method dict for concurrency safety and to handle None returns
if emit:
self._human_feedback_method_outputs[func.__name__] = method_output
return result
wrapper = sync_wrapper

View File

@@ -86,7 +86,7 @@ class ChromaDBClient(BaseClient):
yield
return
lock_cm = store_lock(self._lock_name)
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lock_cm.__enter__)
try:
yield

View File

@@ -266,7 +266,7 @@ class CrewStructuredTool:
# Run sync functions in a thread pool
import asyncio
return await asyncio.get_event_loop().run_in_executor(
return await asyncio.get_running_loop().run_in_executor(
None, lambda: self.func(**parsed_args, **kwargs)
)
except Exception:

View File

@@ -184,7 +184,7 @@ def create_streaming_state(
if use_async:
async_queue = asyncio.Queue()
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
handler = _create_stream_handler(current_task_info, sync_queue, async_queue, loop)
crewai_event_bus.register_handler(LLMStreamChunkEvent, handler)

View File

@@ -246,7 +246,7 @@ class TestHumanFeedbackExecution:
@patch("builtins.input", return_value="")
@patch("builtins.print")
def test_empty_feedback_with_default_outcome(self, mock_print, mock_input):
"""Test empty feedback uses default_outcome."""
"""Test empty feedback uses default_outcome for routing, but flow returns method output."""
class TestFlow(Flow):
@start()
@@ -264,14 +264,16 @@ class TestHumanFeedbackExecution:
with patch.object(flow, "_request_human_feedback", return_value=""):
result = flow.kickoff()
assert result == "needs_work"
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "Content"
assert flow.last_human_feedback is not None
# But the outcome is still correctly set for routing purposes
assert flow.last_human_feedback.outcome == "needs_work"
@patch("builtins.input", return_value="Approved!")
@patch("builtins.print")
def test_feedback_collapsing(self, mock_print, mock_input):
"""Test that feedback is collapsed to an outcome."""
"""Test that feedback is collapsed to an outcome for routing, but flow returns method output."""
class TestFlow(Flow):
@start()
@@ -291,8 +293,10 @@ class TestHumanFeedbackExecution:
):
result = flow.kickoff()
assert result == "approved"
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "Content"
assert flow.last_human_feedback is not None
# But the outcome is still correctly set for routing purposes
assert flow.last_human_feedback.outcome == "approved"
@@ -591,3 +595,162 @@ class TestHumanFeedbackLearn:
assert config.learn is True
# llm defaults to "gpt-4o-mini" at the function level
assert config.llm == "gpt-4o-mini"
class TestHumanFeedbackFinalOutputPreservation:
"""Tests for preserving method return value as flow's final output when @human_feedback with emit is terminal.
This addresses the bug where the flow's final output was the collapsed outcome string (e.g., 'approved')
instead of the method's actual return value when a @human_feedback method with emit is the final method.
"""
@patch("builtins.input", return_value="Looks good!")
@patch("builtins.print")
def test_final_output_is_method_return_not_collapsed_outcome(
self, mock_print, mock_input
):
"""When @human_feedback with emit is the final method, flow output is the method's return value."""
class FinalHumanFeedbackFlow(Flow):
@start()
@human_feedback(
message="Review this content:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def generate_and_review(self):
# This dict should be the final output, NOT the string 'approved'
return {"title": "My Article", "content": "Article content here", "status": "ready"}
flow = FinalHumanFeedbackFlow()
with (
patch.object(flow, "_request_human_feedback", return_value="Looks great, approved!"),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
result = flow.kickoff()
# The final output should be the actual method return value, not the collapsed outcome
assert isinstance(result, dict), f"Expected dict, got {type(result).__name__}: {result}"
assert result == {"title": "My Article", "content": "Article content here", "status": "ready"}
# But the outcome should still be tracked in last_human_feedback
assert flow.last_human_feedback is not None
assert flow.last_human_feedback.outcome == "approved"
@patch("builtins.input", return_value="approved")
@patch("builtins.print")
def test_routing_still_works_with_downstream_listener(self, mock_print, mock_input):
"""When @human_feedback has a downstream listener, routing still triggers the listener."""
publish_called = []
class RoutingFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def review(self):
return {"content": "original content"}
@listen("approved")
def publish(self):
publish_called.append(True)
return {"published": True, "timestamp": "2024-01-01"}
flow = RoutingFlow()
with (
patch.object(flow, "_request_human_feedback", return_value="LGTM"),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
result = flow.kickoff()
# The downstream listener should have been triggered
assert len(publish_called) == 1, "publish() should have been called"
# The final output should be from the listener, not the human_feedback method
assert result == {"published": True, "timestamp": "2024-01-01"}
@patch("builtins.input", return_value="")
@patch("builtins.print")
@pytest.mark.asyncio
async def test_async_human_feedback_final_output_preserved(self, mock_print, mock_input):
"""Async @human_feedback methods also preserve the real return value."""
class AsyncFinalFlow(Flow):
@start()
@human_feedback(
message="Review async content:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
default_outcome="approved",
)
async def async_generate(self):
return {"async_data": "value", "computed": 42}
flow = AsyncFinalFlow()
with (
patch.object(flow, "_request_human_feedback", return_value=""),
):
result = await flow.kickoff_async()
# The final output should be the dict, not "approved"
assert isinstance(result, dict), f"Expected dict, got {type(result).__name__}: {result}"
assert result == {"async_data": "value", "computed": 42}
assert flow.last_human_feedback.outcome == "approved"
@patch("builtins.input", return_value="feedback")
@patch("builtins.print")
def test_method_outputs_contains_real_output(self, mock_print, mock_input):
"""The _method_outputs list should contain the real method output, not the collapsed outcome."""
class OutputTrackingFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def generate(self):
return {"data": "real output"}
flow = OutputTrackingFlow()
with (
patch.object(flow, "_request_human_feedback", return_value="approved"),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
flow.kickoff()
# _method_outputs should contain the real output
assert len(flow._method_outputs) == 1
assert flow._method_outputs[0] == {"data": "real output"}
@patch("builtins.input", return_value="looks good")
@patch("builtins.print")
def test_none_return_value_is_preserved(self, mock_print, mock_input):
"""A method returning None should preserve None as flow output, not the outcome string."""
class NoneReturnFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def process(self):
# Method does work but returns None (implicit)
pass
flow = NoneReturnFlow()
with (
patch.object(flow, "_request_human_feedback", return_value=""),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
result = flow.kickoff()
# Final output should be None (the method's real return), not "approved"
assert result is None, f"Expected None, got {result!r}"
assert flow.last_human_feedback.outcome == "approved"

View File

@@ -708,7 +708,7 @@ class TestEdgeCases:
@patch("builtins.input", return_value="")
@patch("builtins.print")
def test_empty_feedback_first_outcome_fallback(self, mock_print, mock_input):
"""Test that empty feedback without default uses first outcome."""
"""Test that empty feedback without default uses first outcome for routing, but returns method output."""
class FallbackFlow(Flow):
@start()
@@ -726,12 +726,15 @@ class TestEdgeCases:
with patch.object(flow, "_request_human_feedback", return_value=""):
result = flow.kickoff()
assert result == "first" # Falls back to first outcome
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "content"
# But outcome is still set to first for routing purposes
assert flow.last_human_feedback.outcome == "first"
@patch("builtins.input", return_value="whitespace only ")
@patch("builtins.print")
def test_whitespace_only_feedback_treated_as_empty(self, mock_print, mock_input):
"""Test that whitespace-only feedback is treated as empty."""
"""Test that whitespace-only feedback is treated as empty for routing, but returns method output."""
class WhitespaceFlow(Flow):
@start()
@@ -749,7 +752,10 @@ class TestEdgeCases:
with patch.object(flow, "_request_human_feedback", return_value=" "):
result = flow.kickoff()
assert result == "reject" # Uses default because feedback is empty after strip
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "content"
# But outcome is set to default because feedback is empty after strip
assert flow.last_human_feedback.outcome == "reject"
@patch("builtins.input", return_value="feedback")
@patch("builtins.print")

View File

@@ -0,0 +1,210 @@
"""Tests for Python 3.14 compatibility.
Python 3.14 changed asyncio.get_event_loop() to raise RuntimeError when no
running event loop exists instead of creating one. All async code paths must
use asyncio.get_running_loop() instead.
See: https://github.com/crewAIInc/crewAI/issues/5109
"""
import asyncio
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.utilities.streaming import create_streaming_state
class TestStructuredToolAsyncCompat:
"""Test that CrewStructuredTool.ainvoke uses get_running_loop correctly."""
@pytest.mark.asyncio
async def test_ainvoke_sync_func_uses_running_loop(self) -> None:
"""ainvoke() with a sync function must use the running event loop."""
def sync_func(x: int) -> int:
"""A sync function."""
return x * 2
tool = CrewStructuredTool.from_function(
func=sync_func, name="double", description="Doubles a number"
)
result = await tool.ainvoke({"x": 5})
assert result == 10
@pytest.mark.asyncio
async def test_ainvoke_async_func(self) -> None:
"""ainvoke() with an async function should call it directly."""
async def async_func(x: int) -> int:
"""An async function."""
return x * 3
tool = CrewStructuredTool.from_function(
func=async_func, name="triple", description="Triples a number"
)
result = await tool.ainvoke({"x": 4})
assert result == 12
@pytest.mark.asyncio
async def test_ainvoke_sync_func_runs_in_executor(self) -> None:
"""Verify ainvoke offloads sync functions to an executor via the running loop."""
import threading
call_thread_ids: list[int] = []
def sync_func(x: int) -> int:
"""A sync function that records its thread."""
call_thread_ids.append(threading.current_thread().ident or 0)
return x + 1
tool = CrewStructuredTool.from_function(
func=sync_func, name="inc", description="Increment"
)
result = await tool.ainvoke({"x": 1})
assert result == 2
assert len(call_thread_ids) == 1
# Sync func should run in a different thread (executor)
assert call_thread_ids[0] != threading.current_thread().ident
class TestStreamingStateAsyncCompat:
"""Test that create_streaming_state uses get_running_loop correctly."""
@pytest.mark.asyncio
async def test_create_streaming_state_async_uses_running_loop(self) -> None:
"""create_streaming_state(use_async=True) must use the running loop."""
task_info = {
"index": 0,
"name": "test",
"id": "test-id",
"agent_role": "tester",
"agent_id": "agent-id",
}
state = create_streaming_state(
current_task_info=task_info,
result_holder=[],
use_async=True,
)
assert state.loop is not None
assert state.async_queue is not None
assert state.loop is asyncio.get_running_loop()
def test_create_streaming_state_sync_no_loop_needed(self) -> None:
"""create_streaming_state(use_async=False) should not require a loop."""
task_info = {
"index": 0,
"name": "test",
"id": "test-id",
"agent_role": "tester",
"agent_id": "agent-id",
}
state = create_streaming_state(
current_task_info=task_info,
result_holder=[],
use_async=False,
)
assert state.loop is None
assert state.async_queue is None
@pytest.mark.asyncio
async def test_create_streaming_state_async_uses_get_running_loop_not_get_event_loop(
self,
) -> None:
"""Verify create_streaming_state does not call asyncio.get_event_loop()."""
task_info = {
"index": 0,
"name": "test",
"id": "test-id",
"agent_role": "tester",
"agent_id": "agent-id",
}
with patch("crewai.utilities.streaming.asyncio") as mock_asyncio:
mock_asyncio.Queue = asyncio.Queue
mock_asyncio.get_running_loop.return_value = asyncio.get_running_loop()
create_streaming_state(
current_task_info=task_info,
result_holder=[],
use_async=True,
)
mock_asyncio.get_running_loop.assert_called_once()
mock_asyncio.get_event_loop.assert_not_called()
class TestChromaDBClientAsyncCompat:
"""Test that ChromaDBClient._alocked uses get_running_loop correctly."""
@pytest.mark.asyncio
async def test_alocked_without_lock_name(self) -> None:
"""_alocked should yield immediately when no lock name is set."""
from crewai.rag.chromadb.client import ChromaDBClient
mock_client = MagicMock()
mock_ef = MagicMock()
client = ChromaDBClient(
client=mock_client,
embedding_function=mock_ef,
lock_name=None,
)
async with client._alocked():
pass # Should not raise
@pytest.mark.asyncio
async def test_alocked_uses_get_running_loop_not_get_event_loop(self) -> None:
"""Verify _alocked does not call asyncio.get_event_loop()."""
from crewai.rag.chromadb.client import ChromaDBClient
mock_client = MagicMock()
mock_ef = MagicMock()
client = ChromaDBClient(
client=mock_client,
embedding_function=mock_ef,
lock_name="test-lock",
)
with patch("crewai.rag.chromadb.client.asyncio") as mock_asyncio:
loop = asyncio.get_running_loop()
mock_asyncio.get_running_loop.return_value = loop
mock_cm = MagicMock()
with patch("crewai.rag.chromadb.client.store_lock", return_value=mock_cm):
async with client._alocked():
pass
mock_asyncio.get_running_loop.assert_called()
mock_asyncio.get_event_loop.assert_not_called()
class TestGetRunningLoopInAsyncContext:
"""General tests ensuring get_running_loop works in async contexts."""
@pytest.mark.asyncio
async def test_get_running_loop_available_in_async_context(self) -> None:
"""asyncio.get_running_loop() should work in an async context."""
loop = asyncio.get_running_loop()
assert loop is not None
assert loop.is_running()
@pytest.mark.asyncio
async def test_run_in_executor_with_running_loop(self) -> None:
"""run_in_executor should work with get_running_loop()."""
loop = asyncio.get_running_loop()
def sync_work() -> str:
return "done"
result = await loop.run_in_executor(None, sync_work)
assert result == "done"
def test_get_running_loop_raises_outside_async(self) -> None:
"""get_running_loop() should raise RuntimeError outside async context."""
with pytest.raises(RuntimeError):
asyncio.get_running_loop()

View File

@@ -271,7 +271,7 @@ async def test_mixed_sync_async_handler_execution():
timeout=5
)
await asyncio.get_event_loop().run_in_executor(None, wait_for_completion)
await asyncio.get_running_loop().run_in_executor(None, wait_for_completion)
assert len(sync_executed) == 5
assert len(async_executed) == 5

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.12.1"
__version__ = "1.12.2"

View File

@@ -1,7 +1,7 @@
name = "crewai-workspace"
description = "Cutting-edge framework for orchestrating role-playing, autonomous AI agents. By fostering collaborative intelligence, CrewAI empowers agents to work together seamlessly, tackling complex tasks."
readme = "README.md"
requires-python = ">=3.10,<3.14"
requires-python = ">=3.10,<3.15"
authors = [
{ name = "Joao Moura", email = "joao@crewai.com" }
]

9113
uv.lock generated

File diff suppressed because it is too large Load Diff