Coverage for agentos/plugins/loader.py: 0%
159 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 19:15 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 19:15 +0800
1"""
2AgentOS v0.70 — 插件发现与加载器。
3基因来源: Python entry_points + Docker plugin discovery
5加载策略:
61. 入口点扫描 (entry_points.txt / pyproject.toml)
72. 目录扫描 (plugins/ 下的 manifest.json)
83. 环境变量指定 (AGENTOS_PLUGINS)
9"""
11from __future__ import annotations
13import importlib
14import json
15import os
16import sys
17import time
18from pathlib import Path
19from typing import Any
21from agentos.plugins.registry import (
22 DependencyCycleError,
23 PluginManifest,
24 PluginRegistry,
25 PluginStatus,
26 PluginType,
27 RegisteredPlugin,
28)
31class PluginLoadError(Exception):
32 """插件加载错误。"""
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 )
44DEFAULT_PLUGIN_DIRS = [
45 "plugins",
46 os.path.expanduser("~/.agentos/plugins"),
47 "/etc/agentos/plugins",
48]
51class PluginLoader:
52 """插件加载器 — 发现、验证、实例化、热加载。"""
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
62 # ── Discovery ────────────────────────────────
64 def discover(self) -> list[PluginManifest]:
65 """扫描所有搜索路径,发现可用插件。"""
66 manifests: list[PluginManifest] = []
67 seen: set[str] = set()
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)
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)
90 return manifests
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
102 if not manifest_file.exists():
103 return None
105 try:
106 data = json.loads(manifest_file.read_text(encoding="utf-8"))
107 except (json.JSONDecodeError, OSError):
108 return None
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 )
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
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", "")
142 return PluginManifest(
143 name=name,
144 version=version,
145 description=desc.strip(),
146 plugin_type=PluginType.CUSTOM,
147 entry_point=entry_point,
148 )
150 # ── Loading ──────────────────────────────────
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()
166 if not manifests:
167 return self.registry
169 [m.name for m in manifests]
170 order = self._topological_sort(manifests)
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)
189 if auto_start:
190 for name in order:
191 self.registry._plugins[name].status = PluginStatus.ACTIVE
193 return self.registry
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}")
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
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")
225 manifest = old.manifest
226 # stop
227 old.status = PluginStatus.STOPPING
228 if hasattr(old.instance, "stop"):
229 try:
230 import asyncio
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
240 # reload
241 return self.load_one(manifest, auto_start=True)
243 # ── Internal ─────────────────────────────────
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
251 parts = manifest.entry_point.rsplit(".", 1)
252 if len(parts) != 2:
253 raise ValueError(f"Invalid entry_point: {manifest.entry_point}")
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
265 cls = getattr(mod, class_name, None)
266 if cls is None:
267 raise AttributeError(f"Class '{class_name}' not in module '{module_path}'")
269 return cls()
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}
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
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)
293 if len(order) != len(manifests):
294 raise DependencyCycleError("循环依赖")
296 return order