1"""

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

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

4

5 加载策略:

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

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

8 3. 环境变量指定 (AGENTOS_PLUGINS)

9"""

10

11 from __future__ import annotations

12

13 import importlib

14 import json

15 import os

16 import sys

17 import time

18 from pathlib import Path

19 from typing import Any

20

21 from agentos.plugins.registry import (

22 PluginRegistry, RegisteredPlugin, PluginManifest, PluginType, PluginStatus,

23 DependencyCycleError,

24)

25

26

27

28 class 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

38 DEFAULT_PLUGIN_DIRS = [

39 "plugins",

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

41 "/etc/agentos/plugins",

42]

43

44

45 class 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