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
« 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
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 PluginRegistry, RegisteredPlugin, PluginManifest, PluginType, PluginStatus,
23 DependencyCycleError,
24)
28class PluginLoadError(Exception):
30 """插件加载错误。"""
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}'")
38DEFAULT_PLUGIN_DIRS = [
39 "plugins",
40 os.path.expanduser("~/.agentos/plugins"),
41 "/etc/agentos/plugins",
42]
45class PluginLoader:
46 """插件加载器 — 发现、验证、实例化、热加载。"""
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
56 # ── Discovery ────────────────────────────────
58 def discover(self) -> list[PluginManifest]:
59 """扫描所有搜索路径,发现可用插件。"""
60 manifests: list[PluginManifest] = []
61 seen: set[str] = set()
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)
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)
84 return manifests
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
96 if not manifest_file.exists():
97 return None
99 try:
100 data = json.loads(manifest_file.read_text(encoding="utf-8"))
101 except (json.JSONDecodeError, OSError):
102 return None
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 )
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
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", "")
136 return PluginManifest(
137 name=name,
138 version=version,
139 description=desc.strip(),
140 plugin_type=PluginType.CUSTOM,
141 entry_point=entry_point,
142 )
144 # ── Loading ──────────────────────────────────
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()
160 if not manifests:
161 return self.registry
163 names = [m.name for m in manifests]
164 order = self._topological_sort(manifests)
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)
183 if auto_start:
184 for name in order:
185 self.registry._plugins[name].status = PluginStatus.ACTIVE
187 return self.registry
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}")
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
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")
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
233 # reload
234 return self.load_one(manifest, auto_start=True)
236 # ── Internal ─────────────────────────────────
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
244 parts = manifest.entry_point.rsplit(".", 1)
245 if len(parts) != 2:
246 raise ValueError(f"Invalid entry_point: {manifest.entry_point}")
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
258 cls = getattr(mod, class_name, None)
259 if cls is None:
260 raise AttributeError(f"Class '{class_name}' not in module '{module_path}'")
262 return cls()
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}
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
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)
286 if len(order) != len(manifests):
287 raise DependencyCycleError("循环依赖")
289 return order