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

159 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 09:59 +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 PluginRegistry, RegisteredPlugin, PluginManifest, PluginType, PluginStatus, 

23 DependencyCycleError, 

24) 

25 

26 

27 

28class PluginLoadError(Exception): 

29 

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

31 

32 def __init__(self, plugin_name, reason=''): 

33 self.plugin_name = plugin_name 

34 self.reason = reason 

35 super().__init__(f"Failed to load plugin '{plugin_name}': {reason}" if reason else f"Failed to load plugin '{plugin_name}'") 

36 

37 

38DEFAULT_PLUGIN_DIRS = [ 

39 "plugins", 

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

41 "/etc/agentos/plugins", 

42] 

43 

44 

45class PluginLoader: 

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

47 

48 def __init__( 

49 self, 

50 registry: PluginRegistry | None = None, 

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

52 ): 

53 self.registry = registry or PluginRegistry() 

54 self.search_dirs = search_dirs or DEFAULT_PLUGIN_DIRS 

55 

56 # ── Discovery ──────────────────────────────── 

57 

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

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

60 manifests: list[PluginManifest] = [] 

61 seen: set[str] = set() 

62 

63 for search_dir in self.search_dirs: 

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

65 continue 

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

67 manifest = self._load_manifest(entry) 

68 if manifest and manifest.name not in seen: 

69 manifests.append(manifest) 

70 seen.add(manifest.name) 

71 

72 # Also check AGENTOS_PLUGINS env 

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

74 if env_plugins: 

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

76 plugin_dir = plugin_dir.strip() 

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

78 continue 

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

80 if manifest and manifest.name not in seen: 

81 manifests.append(manifest) 

82 seen.add(manifest.name) 

83 

84 return manifests 

85 

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

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

88 if entry.is_dir(): 

89 manifest_file = entry / "manifest.json" 

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

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

92 return self._manifest_from_pyfile(entry) 

93 else: 

94 return None 

95 

96 if not manifest_file.exists(): 

97 return None 

98 

99 try: 

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

101 except (json.JSONDecodeError, OSError): 

102 return None 

103 

104 return PluginManifest( 

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

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

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

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

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

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

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

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

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

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

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

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

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

118 ) 

119 

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

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

122 try: 

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

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

125 return None 

126 mod = importlib.util.module_from_spec(spec) 

127 spec.loader.exec_module(mod) 

128 except Exception: 

129 return None 

130 

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

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

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

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

135 

136 return PluginManifest( 

137 name=name, 

138 version=version, 

139 description=desc.strip(), 

140 plugin_type=PluginType.CUSTOM, 

141 entry_point=entry_point, 

142 ) 

143 

144 # ── Loading ────────────────────────────────── 

145 

146 def load_all( 

147 self, 

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

149 auto_start: bool = False, 

150 ) -> PluginRegistry: 

151 """ 

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

153 - 若未传manifests则先discover 

154 - 按依赖拓扑排序加载 

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

156 """ 

157 if manifests is None: 

158 manifests = self.discover() 

159 

160 if not manifests: 

161 return self.registry 

162 

163 names = [m.name for m in manifests] 

164 order = self._topological_sort(manifests) 

165 

166 for name in order: 

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

168 start = time.time() 

169 try: 

170 instance = self._instantiate(manifest) 

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

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

173 registered.status = PluginStatus.LOADED 

174 except Exception as e: 

175 registered = RegisteredPlugin( 

176 manifest=manifest, 

177 status=PluginStatus.ERROR, 

178 error=str(e), 

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

180 ) 

181 self.registry.register(manifest) 

182 

183 if auto_start: 

184 for name in order: 

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

186 

187 return self.registry 

188 

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

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

191 # Check deps 

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

193 if missing: 

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

195 

196 start = time.time() 

197 try: 

198 instance = self._instantiate(manifest) 

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

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

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

202 return registered 

203 except Exception as e: 

204 registered = RegisteredPlugin( 

205 manifest=manifest, 

206 status=PluginStatus.ERROR, 

207 error=str(e), 

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

209 ) 

210 self.registry.register(manifest) 

211 return registered 

212 

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

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

215 old = self.registry.get(name) 

216 if not old: 

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

218 

219 manifest = old.manifest 

220 # stop 

221 old.status = PluginStatus.STOPPING 

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

223 try: 

224 import asyncio 

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

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

227 else: 

228 old.instance.stop() 

229 except Exception: 

230 pass 

231 old.status = PluginStatus.STOPPED 

232 

233 # reload 

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

235 

236 # ── Internal ───────────────────────────────── 

237 

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

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

240 if not manifest.entry_point: 

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

242 return None 

243 

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

245 if len(parts) != 2: 

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

247 

248 module_path, class_name = parts 

249 try: 

250 mod = importlib.import_module(module_path) 

251 except ImportError: 

252 # Try reloading if already imported 

253 if module_path in sys.modules: 

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

255 else: 

256 raise 

257 

258 cls = getattr(mod, class_name, None) 

259 if cls is None: 

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

261 

262 return cls() 

263 

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

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

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

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

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

269 

270 for m in manifests: 

271 for dep in m.dependencies: 

272 if dep in names: 

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

274 in_degree[m.name] += 1 

275 

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

277 order = [] 

278 while queue: 

279 n = queue.pop(0) 

280 order.append(n) 

281 for successor in adj[n]: 

282 in_degree[successor] -= 1 

283 if in_degree[successor] == 0: 

284 queue.append(successor) 

285 

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

287 raise DependencyCycleError("循环依赖") 

288 

289 return order