Skip to content

Commit 83fd376

Browse files
fix(review): address critical architectural issues from reviewer feedback
- Fix CLI routing regression in __main__.py - replace isidentifier() with proper YAML/prompt detection - Fix thread safety races in integrations/registry.py - add proper instance-level locking - Fix critical NameError in auto.py - migrate remaining lazy-loading functions to _load_optional - Fix dead code in _async_bridge.py - remove unused variable, restore running-loop guard - Fix loop safety in async_agent_scheduler.py - add loop consistency check in stop() - Fix exception handling in framework_adapters/registry.py - improve specificity and logging - Remove unused lru_cache import in auto.py Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
1 parent dcff27a commit 83fd376

6 files changed

Lines changed: 80 additions & 53 deletions

File tree

src/praisonai/praisonai/__main__.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,27 @@ def _is_legacy_invocation(argv: list[str]) -> bool:
1818
"""Check if this is a bare prompt or bare YAML invocation.
1919
2020
Legacy invocations are:
21-
- Bare YAML file: "agents.yaml"
21+
- Bare YAML file: "agents.yaml"
2222
- Free-text prompt: "Create a weather app"
2323
2424
All other invocations should be handled by Typer commands.
2525
"""
26-
for arg in argv:
27-
if arg.startswith("-"):
28-
continue
29-
# Check if it's a YAML file or contains spaces (free-text prompt)
30-
return (arg.endswith((".yaml", ".yml")) or
31-
" " in arg or
32-
not arg.isidentifier())
26+
import os
27+
28+
# Only the very first positional token is considered; option values never are.
29+
if not argv or argv[0].startswith("-"):
30+
return False
31+
32+
first = argv[0]
33+
34+
# Check for free-text prompt (contains spaces)
35+
if " " in first:
36+
return True
37+
38+
# Check for YAML file that actually exists on disk
39+
if first.endswith((".yaml", ".yml")) and os.path.isfile(first):
40+
return True
41+
3342
return False
3443

3544

src/praisonai/praisonai/_async_bridge.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,10 @@ async def _cancel_all() -> None:
7575

7676
def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) -> T:
7777
"""
78-
Run a coroutine synchronously, safe inside a running loop.
78+
Run a coroutine synchronously using the background loop.
7979
80-
This function automatically detects if there's already a running event loop
81-
and handles the execution appropriately:
82-
- If no loop is running: uses background loop (consistent behavior)
83-
- If a loop is running: schedules on background loop (safe path)
80+
IMPORTANT: This function cannot be called from within a running event loop
81+
as it would cause deadlock. Use 'await coro' directly from async contexts.
8482
8583
Args:
8684
coro: The coroutine to run
@@ -90,20 +88,24 @@ def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) ->
9088
The result of the coroutine
9189
9290
Raises:
91+
RuntimeError: If called from within a running event loop
9392
TimeoutError: If timeout is exceeded
9493
Any exception raised by the coroutine
9594
"""
9695
try:
9796
asyncio.get_running_loop()
98-
running = True
9997
except RuntimeError:
100-
running = False
98+
pass
99+
else:
100+
raise RuntimeError(
101+
"run_sync() cannot be called from a running event loop; "
102+
"await the coroutine directly instead."
103+
)
101104

102105
# Submit the coroutine inside the lock to prevent shutdown races
103106
with _BG._lock:
104-
loop = _BG.get_unlocked() # get loop while holding lock
107+
loop = _BG.get_unlocked()
105108
fut: Future = asyncio.run_coroutine_threadsafe(coro, loop)
106-
107109
return fut.result(timeout=timeout)
108110

109111

src/praisonai/praisonai/async_agent_scheduler.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,30 @@ async def stop(self) -> bool:
183183
"""
184184
Stop the scheduler gracefully with proper cancellation.
185185
186+
IMPORTANT: This method must be called from the same event loop
187+
that was used to start the scheduler.
188+
186189
Returns:
187190
True if stopped successfully
191+
192+
Raises:
193+
RuntimeError: If called from a different event loop than start()
188194
"""
189195
if not self._is_running:
190196
logger.info("Scheduler is not running")
191197
return True
198+
199+
# Ensure we're on the same loop that was bound during start()
200+
try:
201+
current_loop = asyncio.get_running_loop()
202+
if self._bound_loop is not None and current_loop is not self._bound_loop:
203+
raise RuntimeError(
204+
"stop() must be called from the same event loop as start(). "
205+
f"Expected: {self._bound_loop}, got: {current_loop}"
206+
)
207+
except RuntimeError:
208+
# No running loop - this is fine if scheduler was never started
209+
pass
192210

193211
logger.info("Stopping async agent scheduler...")
194212
self._cancel_event.set()

src/praisonai/praisonai/auto.py

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
# =============================================================================
2727

2828
import threading
29-
from functools import lru_cache
3029

3130
# Thread-safe lazy cache for optional dependencies
3231
_optional_lock = threading.Lock()
@@ -173,48 +172,38 @@ def tools_loader():
173172
# --- LiteLLM lazy loading ---
174173
def _check_litellm_available() -> bool:
175174
"""Check if litellm is available (cached)."""
176-
global _litellm_available
177-
if _litellm_available is None:
178-
try:
179-
import litellm # noqa: F401
180-
_litellm_available = True
181-
except ImportError:
182-
_litellm_available = False
183-
return _litellm_available
175+
result = _load_optional("litellm")
176+
return result is not None
184177

185178

186179
def _get_litellm():
187180
"""Lazy load litellm module."""
188-
global _litellm
189-
if _litellm is None:
190-
import litellm as _litellm_module
191-
_litellm = _litellm_module
192-
return _litellm
181+
result = _load_optional("litellm")
182+
if result is None:
183+
raise ImportError("Install with: pip install litellm")
184+
return result
193185

194186

195187
# --- OpenAI lazy loading ---
196188
def _check_openai_available() -> bool:
197189
"""Check if openai is available (cached)."""
198-
global _openai_available
199-
if _openai_available is None:
200-
try:
201-
import openai # noqa: F401
202-
_openai_available = True
203-
except ImportError:
204-
_openai_available = False
205-
return _openai_available
190+
result = _load_optional("openai")
191+
return result is not None
206192

207193

208194
def _get_openai_client(api_key: str = None, base_url: str = None):
209195
"""Lazy load OpenAI client."""
210-
global _openai_client
211-
if _openai_client is None:
196+
def create_openai_client():
212197
from openai import OpenAI
213-
_openai_client = OpenAI(
198+
return OpenAI(
214199
api_key=api_key or os.environ.get("OPENAI_API_KEY"),
215200
base_url=base_url
216201
)
217-
return _openai_client
202+
203+
result = _load_optional("openai_client", create_openai_client)
204+
if result is None:
205+
raise ImportError("Install with: pip install openai")
206+
return result
218207

219208

220209
_loglevel = os.environ.get('LOGLEVEL', 'INFO').strip().upper() or 'INFO'

src/praisonai/praisonai/framework_adapters/registry.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ def is_available(self, name: str) -> bool:
167167
"""
168168
try:
169169
adapter = self.create(name)
170+
except ValueError:
171+
return False
172+
173+
try:
170174
return adapter.is_available()
171-
except (ValueError, Exception):
175+
except Exception:
176+
logger.warning("is_available() raised for adapter %r", name, exc_info=True)
172177
return False

src/praisonai/praisonai/integrations/registry.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class ExternalAgentRegistry:
4848
def __init__(self):
4949
"""Initialize the registry with built-in integrations."""
5050
self._integrations: Dict[str, Type[BaseCLIIntegration]] = {}
51+
self._lock = threading.Lock()
5152
self._register_builtin_integrations()
5253

5354
@classmethod
@@ -108,7 +109,7 @@ def register(self, name: str, integration_class: Type[BaseCLIIntegration]) -> No
108109
)
109110

110111
# Thread-safe registration
111-
with self._instance_lock:
112+
with self._lock:
112113
self._integrations[name] = integration_class
113114

114115
def unregister(self, name: str) -> bool:
@@ -122,11 +123,8 @@ def unregister(self, name: str) -> bool:
122123
bool: True if the integration was found and removed, False otherwise
123124
"""
124125
# Thread-safe unregistration with atomic check-then-delete
125-
with self._instance_lock:
126-
if name in self._integrations:
127-
del self._integrations[name]
128-
return True
129-
return False
126+
with self._lock:
127+
return self._integrations.pop(name, None) is not None
130128

131129
def create(self, name: str, **kwargs: Any) -> Optional[BaseCLIIntegration]:
132130
"""
@@ -139,7 +137,9 @@ def create(self, name: str, **kwargs: Any) -> Optional[BaseCLIIntegration]:
139137
Returns:
140138
BaseCLIIntegration: Instance of the integration, or None if not found
141139
"""
142-
integration_class = self._integrations.get(name)
140+
with self._lock:
141+
integration_class = self._integrations.get(name)
142+
143143
if integration_class is None:
144144
return None
145145

@@ -152,7 +152,8 @@ def list_registered(self) -> List[str]:
152152
Returns:
153153
List[str]: List of registered integration names
154154
"""
155-
return list(self._integrations.keys())
155+
with self._lock:
156+
return list(self._integrations.keys())
156157

157158
async def get_available(self) -> Dict[str, bool]:
158159
"""
@@ -164,7 +165,10 @@ async def get_available(self) -> Dict[str, bool]:
164165
import inspect
165166
availability = {}
166167

167-
for name, integration_class in self._integrations.items():
168+
with self._lock:
169+
snapshot = list(self._integrations.items())
170+
171+
for name, integration_class in snapshot:
168172
try:
169173
# Check if constructor requires parameters beyond self
170174
sig = inspect.signature(integration_class.__init__)

0 commit comments

Comments
 (0)