Merge pull request #1548 from MervinPraison/claude/issue-1533-20260424-0833

fix(wrapper): resolve 3 architectural gaps in praisonai wrapper layer
This commit is contained in:
Mervin Praison authored and GitHub committed 2026-04-28 12:15:33 +01:00
commit d0d87a7408
8 files changed
+439 -309

No files matched your search

+5 -2
View File
@@ -35,12 +35,15 @@ __all__ = [
'LocalAgentConfig',
]
# Telemetry initialization state
# Telemetry initialization state - thread-safe (threading imported above)
_telemetry_lock = threading.Lock()
_telemetry_initialized = False
def _ensure_telemetry_defaults() -> None:
"""Apply telemetry env defaults exactly once, on first observability use."""
"""Apply telemetry env defaults exactly once, on first observability use.
Thread-safe implementation using double-checked locking pattern.
"""
global _telemetry_initialized
if _telemetry_initialized: # fast path, OK without lock
return
+68 -139
View File
@@ -3,87 +3,79 @@
PraisonAI CLI — Unified Entry Point.
Single entry point for all CLI invocations.
Routes to Typer-based commands for known subcommands,
falls back to legacy argparse for direct prompts and YAML files.
Makes Typer the single dispatcher with narrow legacy shim for bare prompts/YAML.
Design:
- Typer-first: all registered commands auto-discovered via Click
- Legacy fallback: prompts, .yaml paths, and deprecated --flags
- No manual command lists needed — adding a Typer command Just Works
- Typer owns all command resolution
- Legacy shim only for bare prompt/YAML invocations via Typer callback
- Fail loud on registration errors - no silent degradation
"""
import sys
import threading
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
_typer_commands_cache = None
_typer_commands_lock = threading.Lock()
def _get_typer_commands():
"""Auto-discover registered Typer commands via Click introspection."""
global _typer_commands_cache
# Fast path
if _typer_commands_cache is not None:
return _typer_commands_cache
with _typer_commands_lock:
if _typer_commands_cache is not None: # Double-check
return _typer_commands_cache
try:
from praisonai.cli.app import app, register_commands
register_commands()
import typer.main
import click
click_app = typer.main.get_command(app)
ctx = click.Context(click_app, info_name="praisonai")
commands = set(click_app.list_commands(ctx))
except Exception:
# Do NOT poison the cache on failure — let the next caller retry.
import logging
logging.getLogger("praisonai.__main__").warning(
"Typer command discovery failed; falling back to legacy dispatch.",
exc_info=True,
)
return set()
_typer_commands_cache = commands
return _typer_commands_cache
def _find_first_command(argv):
"""Find the first non-flag argument in argv.
Skips global flags (--json, --verbose, etc.) and their values.
Returns the first positional arg, or None if only flags are present.
def _is_legacy_invocation(argv: list[str]) -> bool:
"""Check if this is a bare prompt or bare YAML invocation.
Legacy invocations are:
- Bare YAML file: "agents.yaml"
- Free-text prompt: "Create a weather app"
All other invocations should be handled by Typer commands.
"""
# Flags that consume a following value
VALUE_FLAGS = {"--output-format", "-o"}
skip_next = False
for arg in argv:
if skip_next:
skip_next = False
continue
if arg.startswith("-"):
if arg in VALUE_FLAGS:
skip_next = True
continue
return arg # First non-flag arg
return None
def _run_typer(argv):
"""Dispatch to the Typer CLI app."""
import os
# Only the very first positional token is considered; option values never are.
if not argv or argv[0].startswith("-"):
return False
first = argv[0]
# Check for free-text prompt (contains spaces)
if " " in first:
return True
# Check for YAML file that actually exists on disk
if first.endswith((".yaml", ".yml")) and os.path.isfile(first):
return True
return False
def main():
"""Unified CLI entry point - Typer is the single dispatcher.
Routing rules (in order):
1. --version / -V → print version and exit
2. Legacy invocation → legacy shim (bare prompts/YAML only)
3. Everything else → Typer (owns all subcommands)
"""
argv = sys.argv[1:]
# 1. Quick version check (minimal imports)
if "--version" in argv or "-V" in argv:
from praisonai.version import __version__
print(f"PraisonAI version {__version__}")
return
# 2. Check for legacy invocation (bare prompt/YAML)
if _is_legacy_invocation(argv):
from praisonai.cli.main import PraisonAI
original = sys.argv
sys.argv = ["praisonai"] + list(argv)
try:
praison = PraisonAI()
result = praison.main()
code = 0 if result is None else (1 if result is False else 0)
sys.exit(code)
except SystemExit as e:
sys.exit(e.code if isinstance(e.code, int) else 0)
finally:
sys.argv = original
return
# 3. All other invocations → Typer (fail loud on registration errors)
import os
# Set up safer encoding for Windows legacy terminals
if sys.platform == "win32" and hasattr(sys.stdout, 'encoding'):
encoding = getattr(sys.stdout, 'encoding', '').lower()
@@ -91,10 +83,12 @@ def _run_typer(argv):
# Force UTF-8 mode for subprocess safety
if 'PYTHONIOENCODING' not in os.environ:
os.environ['PYTHONIOENCODING'] = 'utf-8'
from praisonai.cli.app import app, register_commands
register_commands() # idempotent
from praisonai.cli.app import app, register_commands
# CRITICAL: Fail loud - do not swallow registration exceptions
register_commands() # Let any ImportError/other exceptions propagate
original = sys.argv
sys.argv = ["praisonai"] + list(argv)
try:
@@ -110,70 +104,5 @@ def _run_typer(argv):
sys.argv = original
def _run_legacy(argv):
"""Dispatch to the legacy argparse CLI (prompts, YAML, deprecated flags)."""
from praisonai.cli.main import PraisonAI
original = sys.argv
sys.argv = ["praisonai"] + list(argv)
try:
praison = PraisonAI()
result = praison.main()
code = 0 if result is None else (1 if result is False else 0)
sys.exit(code)
except SystemExit as e:
sys.exit(e.code if isinstance(e.code, int) else 0)
finally:
sys.argv = original
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def main():
"""Unified CLI entry point — Typer-first, legacy fallback.
Routing rules (in order):
1. --version / -V → print version and exit
2. --help / -h → Typer help (global or command-level)
3. No arguments → Typer interactive TUI
4. First arg is a Typer cmd→ Typer (auto-discovered from app.py)
5. Everything else → Legacy (prompt, .yaml, deprecated flags)
"""
argv = sys.argv[1:]
# 1. Quick version check (minimal imports)
if "--version" in argv or "-V" in argv:
from praisonai.version import __version__
print(f"PraisonAI version {__version__}")
return
# 2. Help flags → always Typer (global help or command help)
if "--help" in argv or "-h" in argv:
_run_typer(argv)
return
# 3. No arguments → Typer (interactive TUI)
if not argv:
_run_typer(argv)
return
# 4. Find first non-flag argument and check if it's a Typer command
first_cmd = _find_first_command(argv)
if first_cmd is None:
# Only flags, no command → Typer handles global flags
_run_typer(argv)
return
if first_cmd in _get_typer_commands():
# Known Typer command → Typer
_run_typer(argv)
else:
# Prompt, YAML file, or legacy invocation → legacy
_run_legacy(argv)
if __name__ == "__main__":
main()
+26 -13
View File
@@ -34,6 +34,18 @@ class _BackgroundLoop:
)
self._thread.start()
return self._loop
def get_unlocked(self) -> asyncio.AbstractEventLoop:
"""Get loop assuming caller holds _lock. For run_sync() use only."""
if self._loop is None or self._loop.is_closed():
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(
target=self._loop.run_forever,
name="praisonai-async",
daemon=False,
)
self._thread.start()
return self._loop
def shutdown(self, timeout: float = 5.0) -> None:
with self._lock:
@@ -63,12 +75,10 @@ atexit.register(_BG.shutdown)
def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) -> T:
"""
Run a coroutine synchronously, safe inside a running loop.
Run a coroutine synchronously using the background loop.
This function automatically detects if there's already a running event loop
and handles the execution appropriately:
- If no loop is running: uses background loop (consistent behavior)
- If a loop is running: schedules on background loop (safe path)
IMPORTANT: This function cannot be called from within a running event loop
as it would cause deadlock. Use 'await coro' directly from async contexts.
Args:
coro: The coroutine to run
@@ -78,21 +88,24 @@ def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) ->
The result of the coroutine
Raises:
RuntimeError: If called from within a running event loop
TimeoutError: If timeout is exceeded
Any exception raised by the coroutine
"""
try:
asyncio.get_running_loop()
running = True
except RuntimeError:
running = False
pass
else:
raise RuntimeError(
"run_sync() cannot be called from a running event loop; "
"await the coroutine directly instead."
)
if not running:
# Reuse the background loop instead of creating a new one per call.
fut: Future = asyncio.run_coroutine_threadsafe(coro, _BG.get())
return fut.result(timeout=timeout)
fut = asyncio.run_coroutine_threadsafe(coro, _BG.get())
# Submit the coroutine inside the lock to prevent shutdown races
with _BG._lock:
loop = _BG.get_unlocked()
fut: Future = asyncio.run_coroutine_threadsafe(coro, loop)
return fut.result(timeout=timeout)
+6 -18
View File
@@ -17,10 +17,8 @@ import re
import keyword
# Import new architecture components
from .framework_adapters import (
FrameworkAdapter, CrewAIAdapter, AutoGenAdapter,
AutoGenV4Adapter, AG2Adapter, PraisonAIAdapter
)
from .framework_adapters.base import FrameworkAdapter
from .framework_adapters.registry import FrameworkAdapterRegistry
from .tool_registry import ToolRegistry
# Import availability flags
@@ -51,14 +49,8 @@ try:
except ImportError:
pass
# Registry of available adapters (lazy-loaded)
FRAMEWORK_ADAPTERS = {
"crewai": CrewAIAdapter,
"autogen": AutoGenAdapter,
"autogen_v4": AutoGenV4Adapter,
"ag2": AG2Adapter,
"praisonai": PraisonAIAdapter
}
# Framework adapter registry - now uses proper registry pattern
# This replaces the hardcoded FRAMEWORK_ADAPTERS dict
# Note: OTEL_SDK_DISABLED moved to CLI entry point per issue requirements
@@ -258,12 +250,8 @@ class AgentsGenerator:
Raises:
ValueError: If framework is not supported
"""
if framework not in FRAMEWORK_ADAPTERS:
raise ValueError(f"Unsupported framework: {framework}. "
f"Supported frameworks: {list(FRAMEWORK_ADAPTERS.keys())}")
adapter_class = FRAMEWORK_ADAPTERS[framework]
return adapter_class()
adapter_registry = FrameworkAdapterRegistry.get_instance()
return adapter_registry.create(framework)
def _merge_cli_config(self, config, cli_config):
"""
@@ -19,6 +19,7 @@ warnings.warn(
from .scheduler.shared import ScheduleParser, backoff_delay, safe_call
import asyncio
import logging
import threading
from datetime import datetime
from typing import Optional, Dict, Any, Callable, Union
from abc import ABC, abstractmethod
@@ -121,6 +122,27 @@ class AsyncAgentScheduler:
self._success_count = 0
self._failure_count = 0
# Sync lock for async primitives creation and bound loop tracking
self._primitives_lock = threading.Lock()
self._cancel_event: Optional[asyncio.Event] = None
self._stats_lock: Optional[asyncio.Lock] = None
self._bound_loop: Optional[asyncio.AbstractEventLoop] = None
def _ensure_async_primitives(self) -> None:
"""Create async primitives if they don't exist yet.
Thread-safe and loop-aware: primitives are bound to the current running loop.
If called from a different loop, new primitives are created.
"""
loop = asyncio.get_running_loop() # must be called from a coroutine
with self._primitives_lock:
if self._bound_loop is not loop:
self._cancel_event = asyncio.Event()
self._stats_lock = asyncio.Lock()
self._bound_loop = loop
async def start(
self,
schedule_expr: str,
@@ -173,12 +195,30 @@ class AsyncAgentScheduler:
"""
Stop the scheduler gracefully.
IMPORTANT: This method must be called from the same event loop
that was used to start the scheduler.
Returns:
True if stopped successfully
Raises:
RuntimeError: If called from a different event loop than start()
"""
if not self.is_running:
logger.info("Async scheduler is not running")
return True
# Ensure we're on the same loop that was bound during start()
try:
current_loop = asyncio.get_running_loop()
if self._bound_loop is not None and current_loop is not self._bound_loop:
raise RuntimeError(
"stop() must be called from the same event loop as start(). "
f"Expected: {self._bound_loop}, got: {current_loop}"
)
except RuntimeError:
# No running loop - this is fine if scheduler was never started
pass
logger.info("Stopping async agent scheduler...")
self._stop_event.set()
+101 -129
View File
@@ -24,25 +24,42 @@ from praisonai._logging import get_logger
T = TypeVar('T', bound=BaseModel)
# =============================================================================
# LAZY LOADING INFRASTRUCTURE - All heavy imports are deferred
# THREAD-SAFE LAZY LOADING INFRASTRUCTURE - All heavy imports are deferred
# =============================================================================
# Cached availability flags (None = not checked yet)
_crewai_available = None
_autogen_available = None
_autogen_v4_available = None
_praisonai_available = None
_praisonai_tools_available = None
_litellm_available = None
_openai_available = None
import threading
# Cached module/class references
_crewai_classes = None # (Agent, Task, Crew)
_autogen_module = None
_autogen_v4_classes = None # (AssistantAgent, OpenAIChatCompletionClient)
_praisonai_classes = None # (PraisonAgent, PraisonTask, Agents)
_praisonai_tools = None # dict of tool classes
_litellm = None
# Thread-safe lazy cache for optional dependencies
_optional_lock = threading.Lock()
_optional_cache: dict[str, object] = {}
def _load_optional(key: str, loader):
"""Thread-safe lazy loading for optional dependencies.
Args:
key: Unique key for the dependency
loader: Function that imports and returns the dependency
Returns:
The loaded dependency or None if import fails
"""
if key in _optional_cache:
return _optional_cache[key]
with _optional_lock:
if key in _optional_cache:
return _optional_cache[key]
try:
_optional_cache[key] = loader()
except ImportError:
_optional_cache[key] = None
return _optional_cache[key]
# Bounded LRU cache for OpenAI clients (one per (api_key, base_url) tuple)
_OPENAI_CLIENT_CACHE_MAX = 8
_openai_clients: "OrderedDict[tuple, object]" = OrderedDict()
_openai_clients_lock = threading.Lock()
@@ -50,126 +67,86 @@ _openai_clients_lock = threading.Lock()
# --- CrewAI lazy loading ---
def _check_crewai_available() -> bool:
"""Check if crewai is available (cached)."""
global _crewai_available
if _crewai_available is None:
try:
import crewai # noqa: F401
_crewai_available = True
except ImportError:
_crewai_available = False
return _crewai_available
"""Check if crewai is available (cached, thread-safe)."""
result = _load_optional("crewai_check", lambda: __import__("crewai"))
return result is not None
def _get_crewai():
"""Lazy load crewai classes."""
global _crewai_classes
if _crewai_classes is None:
from crewai import Agent, Task, Crew
_crewai_classes = (Agent, Task, Crew)
return _crewai_classes
"""Lazy load crewai classes (thread-safe)."""
return _load_optional("crewai_classes", lambda: (
__import__("crewai", fromlist=["Agent", "Task", "Crew"]).Agent,
__import__("crewai", fromlist=["Agent", "Task", "Crew"]).Task,
__import__("crewai", fromlist=["Agent", "Task", "Crew"]).Crew,
))
# --- AutoGen lazy loading ---
def _check_autogen_available() -> bool:
"""Check if autogen v0.2 is available (cached)."""
global _autogen_available
if _autogen_available is None:
try:
import autogen # noqa: F401
_autogen_available = True
except ImportError:
_autogen_available = False
return _autogen_available
"""Check if autogen v0.2 is available (cached, thread-safe)."""
result = _load_optional("autogen_check", lambda: __import__("autogen"))
return result is not None
def _check_autogen_v4_available() -> bool:
"""Check if autogen v0.4 is available (cached)."""
global _autogen_v4_available
if _autogen_v4_available is None:
try:
from autogen_agentchat.agents import AssistantAgent # noqa: F401
_autogen_v4_available = True
except ImportError:
_autogen_v4_available = False
return _autogen_v4_available
"""Check if autogen v0.4 is available (cached, thread-safe)."""
result = _load_optional("autogen_v4_check", lambda: __import__("autogen_agentchat.agents", fromlist=["AssistantAgent"]))
return result is not None
# --- AG2 lazy loading ---
_ag2_available = None
def _check_ag2_available() -> bool:
"""Check if AG2 (community fork of AutoGen) is available (cached)."""
global _ag2_available
if _ag2_available is None:
try:
import importlib.metadata
importlib.metadata.distribution('ag2')
from autogen import LLMConfig # noqa: F401 — AG2-exclusive class
_ag2_available = True
except Exception:
_ag2_available = False
return _ag2_available
"""Check if AG2 (community fork of AutoGen) is available (cached, thread-safe)."""
def ag2_loader():
import importlib.metadata
importlib.metadata.distribution('ag2')
from autogen import LLMConfig # AG2-exclusive class
return True
result = _load_optional("ag2_check", ag2_loader)
return result is not None
def _get_autogen():
"""Lazy load autogen module."""
global _autogen_module
if _autogen_module is None:
import autogen
_autogen_module = autogen
return _autogen_module
"""Lazy load autogen module (thread-safe)."""
return _load_optional("autogen_module", lambda: __import__("autogen"))
def _get_autogen_v4():
"""Lazy load autogen v0.4 classes."""
global _autogen_v4_classes
if _autogen_v4_classes is None:
"""Lazy load autogen v0.4 classes (thread-safe)."""
def autogen_v4_loader():
from autogen_agentchat.agents import AssistantAgent
from autogen_ext.models.openai import OpenAIChatCompletionClient
_autogen_v4_classes = (AssistantAgent, OpenAIChatCompletionClient)
return _autogen_v4_classes
return (AssistantAgent, OpenAIChatCompletionClient)
return _load_optional("autogen_v4_classes", autogen_v4_loader)
# --- PraisonAI Agents lazy loading ---
def _check_praisonai_available() -> bool:
"""Check if praisonaiagents is available (cached)."""
global _praisonai_available
if _praisonai_available is None:
try:
import praisonaiagents # noqa: F401
_praisonai_available = True
except ImportError:
_praisonai_available = False
return _praisonai_available
"""Check if praisonaiagents is available (cached, thread-safe)."""
result = _load_optional("praisonai_check", lambda: __import__("praisonaiagents"))
return result is not None
def _get_praisonai():
"""Lazy load praisonaiagents classes."""
global _praisonai_classes
if _praisonai_classes is None:
from praisonaiagents import Agent as PraisonAgent, Task as PraisonTask, AgentTeam
_praisonai_classes = (PraisonAgent, PraisonTask, Agents)
return _praisonai_classes
"""Lazy load praisonaiagents classes (thread-safe)."""
def praisonai_loader():
from praisonaiagents import Agent as PraisonAgent, Task as PraisonTask, AgentTeam as Agents
return (PraisonAgent, PraisonTask, Agents)
return _load_optional("praisonai_classes", praisonai_loader)
# --- PraisonAI Tools lazy loading ---
def _check_praisonai_tools_available() -> bool:
"""Check if praisonai_tools is available (cached)."""
global _praisonai_tools_available
if _praisonai_tools_available is None:
try:
import praisonai_tools # noqa: F401
_praisonai_tools_available = True
except ImportError:
_praisonai_tools_available = False
return _praisonai_tools_available
"""Check if praisonai_tools is available (cached, thread-safe)."""
result = _load_optional("praisonai_tools_check", lambda: __import__("praisonai_tools"))
return result is not None
def _get_praisonai_tools():
"""Lazy load praisonai_tools classes."""
global _praisonai_tools
if _praisonai_tools is None:
"""Lazy load praisonai_tools classes (thread-safe)."""
def tools_loader():
from praisonai_tools import (
CodeDocsSearchTool, CSVSearchTool, DirectorySearchTool, DOCXSearchTool,
DirectoryReadTool, FileReadTool, TXTSearchTool, JSONSearchTool,
@@ -177,7 +154,7 @@ def _get_praisonai_tools():
ScrapeWebsiteTool, WebsiteSearchTool, XMLSearchTool,
YoutubeChannelSearchTool, YoutubeVideoSearchTool
)
_praisonai_tools = {
return {
'CodeDocsSearchTool': CodeDocsSearchTool,
'CSVSearchTool': CSVSearchTool,
'DirectorySearchTool': DirectorySearchTool,
@@ -196,48 +173,40 @@ def _get_praisonai_tools():
'YoutubeChannelSearchTool': YoutubeChannelSearchTool,
'YoutubeVideoSearchTool': YoutubeVideoSearchTool,
}
return _praisonai_tools
return _load_optional("praisonai_tools_dict", tools_loader)
# --- LiteLLM lazy loading ---
def _check_litellm_available() -> bool:
"""Check if litellm is available (cached)."""
global _litellm_available
if _litellm_available is None:
try:
import litellm # noqa: F401
_litellm_available = True
except ImportError:
_litellm_available = False
return _litellm_available
result = _load_optional("litellm_check", lambda: __import__("litellm"))
return result is not None
def _get_litellm():
"""Lazy load litellm module."""
global _litellm
if _litellm is None:
import litellm as _litellm_module
_litellm = _litellm_module
return _litellm
result = _load_optional("litellm", lambda: __import__("litellm"))
if result is None:
raise ImportError("Install with: pip install litellm")
return result
# --- OpenAI lazy loading ---
def _check_openai_available() -> bool:
"""Check if openai is available (cached)."""
global _openai_available
if _openai_available is None:
try:
import openai # noqa: F401
_openai_available = True
except ImportError:
_openai_available = False
return _openai_available
result = _load_optional("openai_check", lambda: __import__("openai"))
return result is not None
def _get_openai_client(api_key: str = None, base_url: str = None):
"""Lazy load OpenAI client with bounded LRU cache (thread-safe, multi-tenant)."""
"""Lazy load OpenAI client with bounded LRU cache (thread-safe, multi-tenant).
Multi-tenant safe: each (api_key, base_url) tuple gets its own cached client.
Bounded by _OPENAI_CLIENT_CACHE_MAX with proper LRU eviction.
"""
key = (api_key or os.environ.get("OPENAI_API_KEY"), base_url)
with _openai_clients_lock:
# Check if client exists and update LRU position
client = _openai_clients.get(key)
@@ -246,10 +215,13 @@ def _get_openai_client(api_key: str = None, base_url: str = None):
return client
# Create new client
from openai import OpenAI
try:
from openai import OpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
client = OpenAI(api_key=key[0], base_url=key[1])
_openai_clients[key] = client
# Bound the cache; close the LRU victim
if len(_openai_clients) > _OPENAI_CLIENT_CACHE_MAX:
_, victim = _openai_clients.popitem(last=False)
@@ -257,7 +229,7 @@ def _get_openai_client(api_key: str = None, base_url: str = None):
victim.close()
except Exception:
pass # Best-effort cleanup
return client
@@ -0,0 +1,177 @@
"""
Framework Adapter Registry for PraisonAI.
Provides a registry pattern for managing framework adapters with entry points support,
enabling dynamic registration and discovery of framework adapters.
Mirrors the design of integrations/registry.py for consistency.
"""
from __future__ import annotations
import threading
from importlib.metadata import entry_points
from typing import Dict, Type, Optional
import logging
from .base import FrameworkAdapter
logger = logging.getLogger(__name__)
class FrameworkAdapterRegistry:
"""
Registry for framework adapters.
Provides centralized management of framework adapters with support
for dynamic registration, entry points discovery, and availability checking.
Uses singleton pattern to ensure consistent state across the application.
"""
_instance: Optional["FrameworkAdapterRegistry"] = None
_instance_lock = threading.Lock()
def __init__(self) -> None:
"""Initialize the registry with built-in adapters."""
self._adapters: Dict[str, Type[FrameworkAdapter]] = {}
self._lock = threading.Lock()
self._register_builtin()
self._register_entry_points()
@classmethod
def get_instance(cls) -> "FrameworkAdapterRegistry":
"""
Get the singleton registry instance.
Returns:
FrameworkAdapterRegistry: The singleton registry
"""
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def _register_builtin(self) -> None:
"""Register built-in framework adapters with lazy imports."""
# Lazy, optional imports - mirrors integrations/registry.py pattern
try:
from .crewai_adapter import CrewAIAdapter
self._adapters["crewai"] = CrewAIAdapter
except ImportError:
pass
try:
from .autogen_adapter import AutoGenAdapter, AutoGenV4Adapter, AG2Adapter
self._adapters["autogen"] = AutoGenAdapter
self._adapters["autogen_v4"] = AutoGenV4Adapter
self._adapters["ag2"] = AG2Adapter
except ImportError:
pass
try:
from .praisonai_adapter import PraisonAIAdapter
self._adapters["praisonai"] = PraisonAIAdapter
except ImportError:
pass
def _register_entry_points(self) -> None:
"""Register framework adapters from entry points."""
try:
for ep in entry_points(group="praisonai.framework_adapters"):
try:
adapter_class = ep.load()
self._adapters[ep.name] = adapter_class
except Exception:
# Do not break framework dispatch because one plugin is broken.
# Surface via structured logging instead of swallowing silently.
logger.warning(
"Failed to load framework adapter %r from entry point",
ep.name,
exc_info=True,
)
except Exception:
# entry_points() might not be available in older Python versions
# or in certain packaging environments
logger.debug("Entry points not available for framework adapters")
def register(self, name: str, adapter_class: Type[FrameworkAdapter]) -> None:
"""
Register a new framework adapter.
Args:
name: Unique name for the adapter
adapter_class: The adapter class (must implement FrameworkAdapter protocol)
"""
# Note: We don't enforce strict type checking here since FrameworkAdapter is a Protocol
# and isinstance() doesn't work with Protocols. The runtime will catch typing issues.
with self._lock:
self._adapters[name] = adapter_class
def unregister(self, name: str) -> bool:
"""
Unregister a framework adapter.
Args:
name: Name of the adapter to unregister
Returns:
bool: True if the adapter was found and removed, False otherwise
"""
with self._lock:
return self._adapters.pop(name, None) is not None
def create(self, name: str) -> FrameworkAdapter:
"""
Create an instance of the specified framework adapter.
Args:
name: Name of the adapter to create
Returns:
FrameworkAdapter: Instance of the adapter
Raises:
ValueError: If the adapter is not found
"""
with self._lock:
adapter_class = self._adapters.get(name)
if adapter_class is None:
raise ValueError(
f"Unsupported framework: {name}. "
f"Registered: {sorted(self._adapters)}"
)
return adapter_class()
def list_registered(self) -> list[str]:
"""
List all registered framework adapter names.
Returns:
list[str]: Sorted list of registered adapter names
"""
with self._lock:
return sorted(self._adapters)
def is_available(self, name: str) -> bool:
"""
Check if a framework adapter is available and functional.
Args:
name: Name of the adapter to check
Returns:
bool: True if adapter exists and is available
"""
try:
adapter = self.create(name)
except ValueError:
return False
try:
return adapter.is_available()
except Exception:
logger.warning("is_available() raised for adapter %r", name, exc_info=True)
return False
@@ -48,6 +48,7 @@ class ExternalAgentRegistry:
def __init__(self):
"""Initialize the registry with built-in integrations."""
self._integrations: Dict[str, Type[BaseCLIIntegration]] = {}
self._lock = threading.Lock()
self._register_builtin_integrations()
@classmethod
@@ -107,7 +108,9 @@ class ExternalAgentRegistry:
f"Integration class {integration_class.__name__} must inherit from BaseCLIIntegration"
)
self._integrations[name] = integration_class
# Thread-safe registration
with self._lock:
self._integrations[name] = integration_class
def unregister(self, name: str) -> bool:
"""
@@ -119,10 +122,9 @@ class ExternalAgentRegistry:
Returns:
bool: True if the integration was found and removed, False otherwise
"""
if name in self._integrations:
del self._integrations[name]
return True
return False
# Thread-safe unregistration with atomic check-then-delete
with self._lock:
return self._integrations.pop(name, None) is not None
def create(self, name: str, **kwargs: Any) -> Optional[BaseCLIIntegration]:
"""
@@ -135,7 +137,9 @@ class ExternalAgentRegistry:
Returns:
BaseCLIIntegration: Instance of the integration, or None if not found
"""
integration_class = self._integrations.get(name)
with self._lock:
integration_class = self._integrations.get(name)
if integration_class is None:
return None
@@ -148,7 +152,8 @@ class ExternalAgentRegistry:
Returns:
List[str]: List of registered integration names
"""
return list(self._integrations.keys())
with self._lock:
return list(self._integrations.keys())
async def get_available(self) -> Dict[str, bool]:
"""
@@ -160,7 +165,10 @@ class ExternalAgentRegistry:
import inspect
availability = {}
for name, integration_class in self._integrations.items():
with self._lock:
snapshot = list(self._integrations.items())
for name, integration_class in snapshot:
try:
# Check if constructor requires parameters beyond self
sig = inspect.signature(integration_class.__init__)