Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions src/praisonai/praisonai/_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Generic plugin registry implementation for PraisonAI.

This module provides a unified registry pattern to replace the divergent singleton
implementations across the codebase. It supports both built-in and entry-point
based plugin discovery with thread-safe registration and dependency injection.
"""

from __future__ import annotations

import threading
from importlib.metadata import entry_points
from typing import Callable, Dict, Generic, Optional, Type, TypeVar
import logging

T = TypeVar("T")
logger = logging.getLogger(__name__)


class PluginRegistry(Generic[T]):
"""Generic plugin registry: builtins + entry points + runtime register().

This replaces the singleton pattern with dependency injection while maintaining
the same functionality. Supports:
- Built-in plugins with lazy loading
- Entry points discovery
- Runtime registration
- Thread-safe operations
"""

def __init__(
self,
*,
entry_point_group: str,
builtins: Optional[Dict[str, Callable[[], Type[T]]]] = None
) -> None:
"""Initialize the registry.

Args:
entry_point_group: Entry points group name for plugin discovery
builtins: Dict of name -> loader function for built-in plugins
"""
self._entry_point_group = entry_point_group
self._items: Dict[str, Type[T]] = {}
self._lock = threading.Lock()

# Load built-in plugins with error handling
if builtins:
for name, loader in builtins.items():
try:
self._items[name] = loader()
Comment on lines +47 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize builtin and entry-point names before storing them.

register() and resolve() both lowercase names, but these two load paths store name/ep.name as-is. A third-party entry point published as Slack becomes impossible to resolve because lookup uses slack while storage kept Slack. Lowercase on insert so every registration path follows the same contract.

Also applies to: 63-67

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/praisonai/praisonai/_registry.py` around lines 47 - 50, Normalize keys
before inserting into the internal registry: when populating self._items from
the builtins loop (the for name, loader in builtins.items() block) and the entry
point loading loop (where ep.name is used), lowercase the name (e.g.,
name.lower() / ep.name.lower()) before using it as a key so storage matches the
lowercase contract used by register() and resolve(); update both insertion sites
to compute a normalized_key and assign self._items[normalized_key] = loader().

except ImportError:
# Built-in plugin dependencies not available, skip
pass
except Exception:
# Log other errors but don't crash initialization
logger.warning("Failed to load built-in plugin %r", name, exc_info=True)

self._load_entry_points()
Comment on lines +46 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep builtin loaders deferred instead of resolving them in __init__.

Executing every builtin loader during registry construction defeats the deferred-loader part of this refactor. The first default-registry creation now imports every builtin plugin up front, which reintroduces optional-dependency side effects and startup cost even when only one plugin is ever used.

🧰 Tools
🪛 Ruff (0.15.12)

[warning] 54-54: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/praisonai/praisonai/_registry.py` around lines 46 - 58, The registry
currently resolves builtin loaders during __init__ by calling loader() and
storing results in self._items, which defeats deferred-loading; instead, keep
the builtin loader callables as deferred entries (e.g., store the loader
callable or a thunk under the same key) and only invoke the callable when the
plugin is actually requested (e.g., in the lookup/get method you already use),
preserving the existing ImportError/Exception handling at invocation time so
optional-dependency failures are handled lazily; update the __init__ handling of
builtins to assign loader (not loader()) and ensure any code that reads
self._items knows to call the loader and replace the entry with the real plugin
on first access.


def _load_entry_points(self) -> None:
"""Load plugins from entry points."""
try:
for ep in entry_points(group=self._entry_point_group):
try:
plugin_class = ep.load()
with self._lock:
self._items[ep.name] = plugin_class
Comment on lines +48 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Entry-point plugin names never normalized to lowercase

Builtins are stored as self._items[name] = loader() (no lowercasing), and entry-point plugins are stored as self._items[ep.name] = plugin_class (also no lowercasing). But every lookup path — resolve(), unregister(), and is_available() — calls name.lower(). This means any third-party plugin whose entry-point name contains an uppercase letter (e.g. "CrewAI", "MyBot") will be stored under its original key but never found by resolution. list_names() will show the original-case key, yet resolve(key) will always raise ValueError for it, silently breaking the extensibility mechanism the PR explicitly advertises.

Suggested change
for name, loader in builtins.items():
try:
self._items[name] = loader()
except ImportError:
# Built-in plugin dependencies not available, skip
pass
except Exception:
# Log other errors but don't crash initialization
logger.warning("Failed to load built-in plugin %r", name, exc_info=True)
self._load_entry_points()
def _load_entry_points(self) -> None:
"""Load plugins from entry points."""
try:
for ep in entry_points(group=self._entry_point_group):
try:
plugin_class = ep.load()
with self._lock:
self._items[ep.name] = plugin_class
# Load built-in plugins with error handling
if builtins:
for name, loader in builtins.items():
try:
self._items[name.lower()] = loader()

except Exception:
# Do not break plugin dispatch because one plugin is broken
logger.warning(
"Failed to load plugin %r from entry point",
ep.name,
exc_info=True,
)
except Exception:
# entry_points() might not be available in older Python versions
logger.debug("Entry points not available for group %s", self._entry_point_group)

def register(self, name: str, cls: Type[T]) -> None:
"""Register a plugin at runtime.

Args:
name: Plugin name
cls: Plugin class
"""
with self._lock:
self._items[name.lower()] = cls

def unregister(self, name: str) -> bool:
"""Unregister a plugin.

Args:
name: Plugin name

Returns:
True if plugin was found and removed, False otherwise
"""
with self._lock:
return self._items.pop(name.lower(), None) is not None

def resolve(self, name: str) -> Type[T]:
"""Resolve a plugin name to its class.

Args:
name: Plugin name

Returns:
Plugin class

Raises:
ValueError: If plugin is not found
"""
with self._lock:
cls = self._items.get(name.lower())
# Capture available plugins snapshot while holding lock
# to avoid race condition between check and error message
available_snapshot = sorted(self._items.keys()) if cls is None else None

if cls is None:
raise ValueError(
f"Unknown {self._entry_point_group} plugin: {name!r}. "
f"Available: {available_snapshot}"
)
Comment on lines +113 to +123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Build the “available” snapshot while still holding the lock.

If another thread registers or unregisters between Line 114 and Line 117, iterating self._items.keys() outside the lock can fail with RuntimeError: dictionary changed size during iteration instead of the intended ValueError. Snapshot both cls and available under the same lock to preserve the thread-safe API guarantee.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/praisonai/praisonai/_registry.py` around lines 113 - 121, The snapshot of
available plugins is built outside the critical section, risking a RuntimeError
if self._items changes between reading cls and computing available; update the
code in the method that accesses self._lock/_items/_entry_point_group so both
cls and available are captured inside the same with self._lock block (i.e., move
sorted(self._items.keys()) into the with self._lock block and assign both cls
and available there) and then raise the ValueError using those locked snapshots.

return cls

def create(self, name: str, *args, **kwargs) -> T:
"""Create an instance of the specified plugin.

Args:
name: Plugin name
*args, **kwargs: Arguments to pass to plugin constructor

Returns:
Plugin instance

Raises:
ValueError: If plugin is not found
"""
cls = self.resolve(name)
return cls(*args, **kwargs)

def list_names(self) -> list[str]:
"""List all registered plugin names.

Returns:
Sorted list of plugin names
"""
with self._lock:
return sorted(self._items.keys())

def is_available(self, name: str) -> bool:
"""Check if a plugin is available.

Args:
name: Plugin name

Returns:
True if plugin exists and can be created
"""
try:
self.resolve(name)
return True
except ValueError:
return False
12 changes: 8 additions & 4 deletions src/praisonai/praisonai/agents_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

# Import new architecture components
from .framework_adapters.base import FrameworkAdapter
from .framework_adapters.registry import FrameworkAdapterRegistry
from .framework_adapters.registry import FrameworkAdapterRegistry, get_default_registry
from .tool_registry import ToolRegistry

# Import availability flags
Expand Down Expand Up @@ -179,7 +179,7 @@ def _resolve_yaml_cli_backend(cli_backend_config, logger):


class AgentsGenerator:
def __init__(self, agent_file, framework, config_list, log_level=None, agent_callback=None, task_callback=None, agent_yaml=None, tools=None, cli_config=None):
def __init__(self, agent_file, framework, config_list, log_level=None, agent_callback=None, task_callback=None, agent_yaml=None, tools=None, cli_config=None, adapter_registry=None):
"""
Initialize the AgentsGenerator object.

Expand All @@ -193,6 +193,7 @@ def __init__(self, agent_file, framework, config_list, log_level=None, agent_cal
agent_yaml (str, optional): The content of the YAML file. Defaults to None.
tools (dict, optional): A dictionary containing the tools to be used for the agents. Defaults to None.
cli_config (dict, optional): CLI configuration to override YAML settings. Defaults to None.
adapter_registry (FrameworkAdapterRegistry, optional): Registry for framework adapters. Defaults to process default.

Attributes:
agent_file (str): The path to the agent file.
Expand Down Expand Up @@ -233,6 +234,10 @@ def __init__(self, agent_file, framework, config_list, log_level=None, agent_cal
self.tool_registry = ToolRegistry()
self.tool_registry.register_builtin_autogen_adapters()

# DI-friendly: tests/multi-tenant runtimes pass their own registry;
# CLI users get the process default.
self._adapter_registry = adapter_registry or get_default_registry()

# Get framework adapter (availability already validated at CLI entry)
self.framework_adapter = self._get_framework_adapter(framework)

Expand All @@ -249,8 +254,7 @@ def _get_framework_adapter(self, framework: str) -> FrameworkAdapter:
Raises:
ValueError: If framework is not supported
"""
adapter_registry = FrameworkAdapterRegistry.get_instance()
return adapter_registry.create(framework)
return self._adapter_registry.create(framework)

def _merge_cli_config(self, config, cli_config):
"""
Expand Down
11 changes: 6 additions & 5 deletions src/praisonai/praisonai/auto.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,8 @@ class AutoGenerator(BaseAutoGenerator):

def __init__(self, topic="Movie Story writing about AI", agent_file="test.yaml",
framework="crewai", config_list: Optional[List[Dict]] = None,
pattern: str = "sequential", single_agent: bool = False):
pattern: str = "sequential", single_agent: bool = False,
adapter_registry=None):
"""
Initialize the AutoGenerator class with the specified topic, agent file, and framework.

Expand All @@ -646,15 +647,15 @@ def __init__(self, topic="Movie Story writing about AI", agent_file="test.yaml",
super().__init__(config_list=config_list)

# Validate framework availability using adapter registry
from .framework_adapters.registry import FrameworkAdapterRegistry
from .framework_adapters.registry import get_default_registry

registry = FrameworkAdapterRegistry.get_instance()
self._adapter_registry = adapter_registry or get_default_registry()
try:
adapter = registry.create(framework)
adapter = self._adapter_registry.create(framework)
except ValueError as e:
raise ImportError(
f"Unknown framework '{framework}'. Available frameworks: "
f"{', '.join(registry.list_registered())}"
f"{', '.join(self._adapter_registry.list_registered())}"
) from e

# Use safe fallbacks for new adapter attributes
Expand Down
Loading