1"""

2 Plugin Discovery — entry_points based plugin auto-discovery for AgentOS.

3

4 Scans installed packages for entry_points registered under the

5'agentos.plugins' group and loads them without manual registration.

6"""

7

8 from __future__ import annotations

9

10 import importlib.metadata

11 import logging

12 from dataclasses import dataclass, field

13 from typing import Any, Callable, Dict, List, Optional, Protocol

14

15 logger = logging.getLogger(__name__)

16

17

18 class PluginProtocol(Protocol):

19 """Minimal protocol that discovered plugins must satisfy."""

20

21 name: str

22 version: str

23

24 def initialize(self) -> None: ...

25 def shutdown(self) -> None: ...

26

27

28@dataclass

29 class DiscoveredPlugin:

30 """Represents a plugin discovered via entry_points."""

31

32 name: str

33 version: str

34 entry_point_group: str

35 entry_point_name: str

36 package_name: str

37 module_path: str

38 metadata: Dict[str, Any] = field(default_factory=dict)

39 instance: Optional[Any] = field(default=None, repr=False)

40

41 @property

42 def is_loaded(self) -> bool:

43 return self.instance is not None

44

45

46@dataclass

47 class DiscoveryResult:

48 """Result of a plugin discovery scan."""

49

50 plugins: List[DiscoveredPlugin]

51 total_found: int

52 total_loaded: int

53 errors: List[str] = field(default_factory=list)

54 scan_duration_ms: float = 0.0

55

56

57 class PluginDiscovery:

58 """Scans installed packages for AgentOS plugins via entry_points."""

59

60 DEFAULT_GROUPS = [

61 "agentos.plugins",

62 "agentos.tools",

63 "agentos.models",

64 "agentos.middleware",

65 ]

66

67 def __init__(self, groups: Optional[List[str]] = None):

68 self._groups = groups or self.DEFAULT_GROUPS

69 self._discovered: Dict[str, DiscoveredPlugin] = {}

70 self._loaders: Dict[str, Callable] = {}

71

72 @property

73 def discovered(self) -> Dict[str, DiscoveredPlugin]:

74 return dict(self._discovered)

75

76 @property

77 def groups(self) -> List[str]:

78 return list(self._groups)

79

80 def register_loader(self, group: str, loader: Callable) -> None:

81 """Register a custom loader for a specific entry_point group."""

82 self._loaders[group] = loader

83

84 def scan(self, groups: Optional[List[str]] = None) -> DiscoveryResult:

85 """Scan for plugins across specified (or all registered) groups."""

86 import time

87 start = time.perf_counter()

88 target_groups = groups or self._groups

89 plugins: List[DiscoveredPlugin] = []

90 errors: List[str] = []

91

92 for group in target_groups:

93 try:

94 entry_points = importlib.metadata.entry_points(group=group)

95 except TypeError:

96 # Python 3.11 fallback

97 all_eps = importlib.metadata.entry_points()

98 entry_points = []

99 for ep in all_eps:

100 if ep.group == group:

101 entry_points.append(ep)

102

103 for ep in entry_points:

104 try:

105 pkg = ep.dist.name if ep.dist else "unknown"

106 plugin = DiscoveredPlugin(

107 name=ep.name,

108 version=ep.dist.version if ep.dist else "0.0.0",

109 entry_point_group=group,

110 entry_point_name=ep.name,

111 package_name=pkg,

112 module_path=ep.value,

113 metadata={"group": group},

114 )

115 plugins.append(plugin)

116 self._discovered[f"{group}:{ep.name}"] = plugin

117 except Exception as e:

118 errors.append(f"Failed to parse {ep.name} in {group}: {e}")

119

120 elapsed = (time.perf_counter() - start) * 1000

121 return DiscoveryResult(

122 plugins=plugins,

123 total_found=len(plugins),

124 total_loaded=0,

125 errors=errors,

126 scan_duration_ms=elapsed,

127 )

128

129 def load_plugin(

130 self, name: str, group: str = "agentos.plugins"

131 ) -> Optional[DiscoveredPlugin]:

132 """Load a specific discovered plugin by name and group."""

133 key = f"{group}:{name}"

134 plugin = self._discovered.get(key)

135 if plugin is None:

136 logger.warning(f"Plugin '{key}' not found in discovered set.")

137 return None

138

139 try:

140 loader = self._loaders.get(group, _default_plugin_loader)

141 instance = loader(plugin.module_path)

142 plugin.instance = instance

143 if hasattr(instance, "initialize"):

144 instance.initialize()

145 return plugin

146 except Exception as e:

147 logger.error(f"Failed to load plugin '{key}': {e}")

148 return None

149

150 def load_all(

151 self, group: Optional[str] = None

152 ) -> Dict[str, DiscoveredPlugin]:

153 """Load all discovered plugins, optionally scoped to one group."""

154 loaded: Dict[str, DiscoveredPlugin] = {}

155 for key, plugin in self._discovered.items():

156 if group and not key.startswith(f"{group}:"):

157 continue

158 result = self.load_plugin(plugin.name, plugin.entry_point_group)

159 if result and result.is_loaded:

160 loaded[key] = result

161 return loaded

162

163 def get_by_package(self, package_name: str) -> List[DiscoveredPlugin]:

164 """Get all discovered plugins from a specific package."""

165 return [

166 p for p in self._discovered.values()

167 if p.package_name == package_name

168 ]

169

170 def get_by_group(self, group: str) -> List[DiscoveredPlugin]:

171 """Get all discovered plugins in a specific group."""

172 return [

173 p for p in self._discovered.values()

174 if p.entry_point_group == group

175 ]

176

177 def summary(self) -> Dict[str, Any]:

178 """Return a summary of all discovered plugins."""

179 groups_summary: Dict[str, int] = {}

180 for p in self._discovered.values():

181 groups_summary[p.entry_point_group] = (

182 groups_summary.get(p.entry_point_group, 0) + 1

183 )

184 return {

185 "total_plugins": len(self._discovered),

186 "by_group": groups_summary,

187 "loaded": sum(1 for p in self._discovered.values() if p.is_loaded),

188 }

189

190 def clear(self) -> None:

191 """Clear all discovered plugins."""

192 self._discovered.clear()

193

194

195 def _default_plugin_loader(module_path: str) -> Any:

196 """Default loader: import the module and look for a Plugin class."""

197 import importlib

198

199 module = importlib.import_module(module_path)

200 # Try common class names

201 for attr_name in ("Plugin", "AgentOSPlugin", "plugin", "__plugin__"):

202 if hasattr(module, attr_name):

203 return getattr(module, attr_name)

204 return module