Coverage for agentos/plugins/loader.py: 0%

159 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-08 20:40 +0800

1""" 

2AgentOS v0.70 — 插件发现与加载器。 

3基因来源: Python entry_points + Docker plugin discovery 

4 

5加载策略: 

61. 入口点扫描 (entry_points.txt / pyproject.toml) 

72. 目录扫描 (plugins/ 下的 manifest.json) 

83. 环境变量指定 (AGENTOS_PLUGINS) 

9""" 

10 

11from __future__ import annotations 

12 

13import importlib 

14import json 

15import os 

16import sys 

17import time 

18from pathlib import Path 

19from typing import Any 

20 

21from agentos.plugins.registry import ( 

22 DependencyCycleError, 

23 PluginManifest, 

24 PluginRegistry, 

25 PluginStatus, 

26 PluginType, 

27 RegisteredPlugin, 

28) 

29 

30 

31class PluginLoadError(Exception): 

32 """插件加载错误。""" 

33 

34 def __init__(self, plugin_name, reason=""): 

35 self.plugin_name = plugin_name 

36 self.reason = reason 

37 super().__init__( 

38 f"Failed to load plugin '{plugin_name}': {reason}" 

39 if reason 

40 else f"Failed to load plugin '{plugin_name}'" 

41 ) 

42 

43 

44DEFAULT_PLUGIN_DIRS = [ 

45 "plugins", 

46 os.path.expanduser("~/.agentos/plugins"), 

47 "/etc/agentos/plugins", 

48] 

49 

50 

51class PluginLoader: 

52 """插件加载器 — 发现、验证、实例化、热加载。""" 

53 

54 def __init__( 

55 self, 

56 registry: PluginRegistry | None = None, 

57 search_dirs: list[str] | None = None, 

58 ): 

59 self.registry = registry or PluginRegistry() 

60 self.search_dirs = search_dirs or DEFAULT_PLUGIN_DIRS 

61 

62 # ── Discovery ──────────────────────────────── 

63 

64 def discover(self) -> list[PluginManifest]: 

65 """扫描所有搜索路径,发现可用插件。""" 

66 manifests: list[PluginManifest] = [] 

67 seen: set[str] = set() 

68 

69 for search_dir in self.search_dirs: 

70 if not os.path.isdir(search_dir): 

71 continue 

72 for entry in Path(search_dir).iterdir(): 

73 manifest = self._load_manifest(entry) 

74 if manifest and manifest.name not in seen: 

75 manifests.append(manifest) 

76 seen.add(manifest.name) 

77 

78 # Also check AGENTOS_PLUGINS env 

79 env_plugins = os.environ.get("AGENTOS_PLUGINS", "") 

80 if env_plugins: 

81 for plugin_dir in env_plugins.split(":"): 

82 plugin_dir = plugin_dir.strip() 

83 if not plugin_dir or not os.path.isdir(plugin_dir): 

84 continue 

85 manifest = self._load_manifest(Path(plugin_dir)) 

86 if manifest and manifest.name not in seen: 

87 manifests.append(manifest) 

88 seen.add(manifest.name) 

89 

90 return manifests 

91 

92 def _load_manifest(self, entry: Path) -> PluginManifest | None: 

93 """从目录或.py文件加载插件清单。""" 

94 if entry.is_dir(): 

95 manifest_file = entry / "manifest.json" 

96 elif entry.suffix == ".py": 

97 # Single-file plugin: infer manifest from __doc__ and filename 

98 return self._manifest_from_pyfile(entry) 

99 else: 

100 return None 

101 

102 if not manifest_file.exists(): 

103 return None 

104 

105 try: 

106 data = json.loads(manifest_file.read_text(encoding="utf-8")) 

107 except (json.JSONDecodeError, OSError): 

108 return None 

109 

110 return PluginManifest( 

111 name=data.get("name", entry.name), 

112 version=data.get("version", "0.1.0"), 

113 description=data.get("description", ""), 

114 author=data.get("author", ""), 

115 plugin_type=PluginType(data.get("plugin_type", "custom")), 

116 entry_point=data.get("entry_point", ""), 

117 dependencies=data.get("dependencies", []), 

118 optional_dependencies=data.get("optional_dependencies", []), 

119 tags=data.get("tags", []), 

120 config_schema=data.get("config_schema", {}), 

121 priority=data.get("priority", 50), 

122 homepage=data.get("homepage", ""), 

123 license=data.get("license", "MIT"), 

124 ) 

125 

126 def _manifest_from_pyfile(self, pyfile: Path) -> PluginManifest | None: 

127 """从单文件Python插件推断清单。""" 

128 try: 

129 spec = importlib.util.spec_from_file_location(pyfile.stem, str(pyfile)) 

130 if spec is None or spec.loader is None: 

131 return None 

132 mod = importlib.util.module_from_spec(spec) 

133 spec.loader.exec_module(mod) 

134 except Exception: 

135 return None 

136 

137 name = getattr(mod, "PLUGIN_NAME", pyfile.stem) 

138 version = getattr(mod, "PLUGIN_VERSION", "0.1.0") 

139 desc = getattr(mod, "PLUGIN_DESCRIPTION", mod.__doc__ or "") 

140 entry_point = getattr(mod, "PLUGIN_ENTRY_POINT", "") 

141 

142 return PluginManifest( 

143 name=name, 

144 version=version, 

145 description=desc.strip(), 

146 plugin_type=PluginType.CUSTOM, 

147 entry_point=entry_point, 

148 ) 

149 

150 # ── Loading ────────────────────────────────── 

151 

152 def load_all( 

153 self, 

154 manifests: list[PluginManifest] | None = None, 

155 auto_start: bool = False, 

156 ) -> PluginRegistry: 

157 """ 

158 加载所有插件到注册中心。 

159 - 若未传manifests则先discover 

160 - 按依赖拓扑排序加载 

161 - 可选auto_start时初始化并激活 

162 """ 

163 if manifests is None: 

164 manifests = self.discover() 

165 

166 if not manifests: 

167 return self.registry 

168 

169 [m.name for m in manifests] 

170 order = self._topological_sort(manifests) 

171 

172 for name in order: 

173 manifest = next(m for m in manifests if m.name == name) 

174 start = time.time() 

175 try: 

176 instance = self._instantiate(manifest) 

177 registered = self.registry.register(manifest, instance) 

178 registered.load_time_ms = (time.time() - start) * 1000 

179 registered.status = PluginStatus.LOADED 

180 except Exception as e: 

181 registered = RegisteredPlugin( 

182 manifest=manifest, 

183 status=PluginStatus.ERROR, 

184 error=str(e), 

185 load_time_ms=(time.time() - start) * 1000, 

186 ) 

187 self.registry.register(manifest) 

188 

189 if auto_start: 

190 for name in order: 

191 self.registry._plugins[name].status = PluginStatus.ACTIVE 

192 

193 return self.registry 

194 

195 def load_one(self, manifest: PluginManifest, auto_start: bool = True) -> RegisteredPlugin: 

196 """加载单个插件。""" 

197 # Check deps 

198 missing = self.registry.check_requirements(manifest.name) 

199 if missing: 

200 raise DependencyCycleError(f"Plugin '{manifest.name}': missing deps {missing}") 

201 

202 start = time.time() 

203 try: 

204 instance = self._instantiate(manifest) 

205 registered = self.registry.register(manifest, instance) 

206 registered.load_time_ms = (time.time() - start) * 1000 

207 registered.status = PluginStatus.ACTIVE if auto_start else PluginStatus.LOADED 

208 return registered 

209 except Exception as e: 

210 registered = RegisteredPlugin( 

211 manifest=manifest, 

212 status=PluginStatus.ERROR, 

213 error=str(e), 

214 load_time_ms=(time.time() - start) * 1000, 

215 ) 

216 self.registry.register(manifest) 

217 return registered 

218 

219 def hot_reload(self, name: str) -> RegisteredPlugin: 

220 """热重载插件:停止→重新加载→启动。""" 

221 old = self.registry.get(name) 

222 if not old: 

223 raise KeyError(f"Plugin '{name}' not registered") 

224 

225 manifest = old.manifest 

226 # stop 

227 old.status = PluginStatus.STOPPING 

228 if hasattr(old.instance, "stop"): 

229 try: 

230 import asyncio 

231 

232 if asyncio.iscoroutinefunction(old.instance.stop): 

233 asyncio.get_event_loop().run_until_complete(old.instance.stop()) 

234 else: 

235 old.instance.stop() 

236 except Exception: 

237 pass 

238 old.status = PluginStatus.STOPPED 

239 

240 # reload 

241 return self.load_one(manifest, auto_start=True) 

242 

243 # ── Internal ───────────────────────────────── 

244 

245 def _instantiate(self, manifest: PluginManifest) -> Any: 

246 """从entry_point实例化插件类。""" 

247 if not manifest.entry_point: 

248 # Static plugin (no executable code, just manifest declaration) 

249 return None 

250 

251 parts = manifest.entry_point.rsplit(".", 1) 

252 if len(parts) != 2: 

253 raise ValueError(f"Invalid entry_point: {manifest.entry_point}") 

254 

255 module_path, class_name = parts 

256 try: 

257 mod = importlib.import_module(module_path) 

258 except ImportError: 

259 # Try reloading if already imported 

260 if module_path in sys.modules: 

261 mod = importlib.reload(sys.modules[module_path]) 

262 else: 

263 raise 

264 

265 cls = getattr(mod, class_name, None) 

266 if cls is None: 

267 raise AttributeError(f"Class '{class_name}' not in module '{module_path}'") 

268 

269 return cls() 

270 

271 def _topological_sort(self, manifests: list[PluginManifest]) -> list[str]: 

272 """依赖拓扑排序。""" 

273 names = {m.name for m in manifests} 

274 adj: dict[str, set[str]] = {m.name: set() for m in manifests} 

275 in_degree: dict[str, int] = {m.name: 0 for m in manifests} 

276 

277 for m in manifests: 

278 for dep in m.dependencies: 

279 if dep in names: 

280 adj[dep].add(m.name) # dep → m 

281 in_degree[m.name] += 1 

282 

283 queue = [n for n in names if in_degree[n] == 0] 

284 order = [] 

285 while queue: 

286 n = queue.pop(0) 

287 order.append(n) 

288 for successor in adj[n]: 

289 in_degree[successor] -= 1 

290 if in_degree[successor] == 0: 

291 queue.append(successor) 

292 

293 if len(order) != len(manifests): 

294 raise DependencyCycleError("循环依赖") 

295 

296 return order