From 851104b133921344eb3815337f8e54f6ea30f4d4 Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 05:00:49 +0800 Subject: [PATCH 1/2] fix: make plugin imports rollback-safe --- astrbot/core/provider/register.py | 31 ++++ astrbot/core/star/star_manager.py | 146 +++++++++++---- tests/test_plugin_manager.py | 293 ++++++++++++++++++++++++++++++ 3 files changed, 436 insertions(+), 34 deletions(-) diff --git a/astrbot/core/provider/register.py b/astrbot/core/provider/register.py index 409f22f376..a8d7ae33eb 100644 --- a/astrbot/core/provider/register.py +++ b/astrbot/core/provider/register.py @@ -51,3 +51,34 @@ def decorator(cls): return cls return decorator + + +def unregister_provider_adapters_by_module(module_path_prefix: str) -> list[str]: + """Unregister provider adapters declared by one plugin module tree. + + Args: + module_path_prefix: Complete plugin module prefix to unregister. + + Returns: + Adapter type names removed from the registries. + """ + to_remove = [] + for metadata in provider_registry: + module_path = getattr(metadata.cls_type, "__module__", None) + if module_path and ( + module_path == module_path_prefix + or module_path.startswith(f"{module_path_prefix}.") + ): + to_remove.append(metadata) + + for metadata in to_remove: + provider_registry.remove(metadata) + if provider_cls_map.get(metadata.type) is metadata: + provider_cls_map.pop(metadata.type, None) + logger.debug( + "Model provider unregistered: %s (from module %s)", + metadata.type, + getattr(metadata.cls_type, "__module__", None), + ) + + return [metadata.type for metadata in to_remove] diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index c9ef92e5ce..6c5acddf34 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -31,7 +31,10 @@ from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.config.default import VERSION from astrbot.core.platform.register import unregister_platform_adapters_by_module -from astrbot.core.provider.register import llm_tools +from astrbot.core.provider.register import ( + llm_tools, + unregister_provider_adapters_by_module, +) from astrbot.core.utils.astrbot_path import ( get_astrbot_config_path, get_astrbot_path, @@ -455,6 +458,9 @@ async def _import_plugin_with_dependency_recovery( try: return __import__(path, fromlist=[module_str]) except ModuleNotFoundError as import_exc: + # The failed import may already have executed decorators. Clear only + # this plugin's partial registrations before retrying in-process. + self._cleanup_plugin_state(root_dir_name, reserved) if recovery_state.mode in { ImportDependencyRecoveryMode.PRELOAD_AND_RECOVER, ImportDependencyRecoveryMode.RECOVER_ON_FAILURE, @@ -468,6 +474,7 @@ async def _import_plugin_with_dependency_recovery( ) if recovered_module is not None: return recovered_module + self._cleanup_plugin_state(root_dir_name, reserved) elif ( recovery_state.mode is ImportDependencyRecoveryMode.REINSTALL_ON_FAILURE ): @@ -480,6 +487,7 @@ async def _import_plugin_with_dependency_recovery( ) await self._check_plugin_dept_update(target_plugin=root_dir_name) + self._cleanup_plugin_state(root_dir_name, reserved) return __import__(path, fromlist=[module_str]) @staticmethod @@ -731,6 +739,25 @@ def _is_plugin_module_path(module_path: str | None, module_prefix: str) -> bool: ) ) + @staticmethod + def _plugin_root_module_path(plugin_module_path: str) -> str: + """Return the import prefix shared by every module in one plugin. + + Args: + plugin_module_path: Plugin main or child module path. + + Returns: + Plugin package import prefix. + """ + parts = plugin_module_path.split(".") + for marker in ("plugins", "builtin_stars"): + if marker not in parts: + continue + marker_index = parts.index(marker) + if marker_index + 1 < len(parts): + return ".".join(parts[: marker_index + 2]) + return plugin_module_path + def _purge_modules( self, module_patterns: list[str] | None = None, @@ -802,14 +829,19 @@ def _cleanup_plugin_state(self, dir_name: str, is_reserved: bool = False) -> Non # 清理工具 for tool in list(llm_tools.func_list): - handler_module_path = getattr(tool, "handler_module_path", None) - if self._is_plugin_module_path(handler_module_path, module_prefix): + if any( + self._is_plugin_llm_tool(concrete_tool, module_prefix) + for concrete_tool in self._iter_concrete_llm_tools(tool) + ): llm_tools.func_list.remove(tool) logger.info(f"Removed tool: {tool.name}") for adapter_name in unregister_platform_adapters_by_module(module_prefix): logger.info(f"Removed platform adapter: {adapter_name}") + for adapter_name in unregister_provider_adapters_by_module(module_prefix): + logger.info(f"Removed provider adapter: {adapter_name}") + def _build_failed_plugin_record( self, *, @@ -877,14 +909,15 @@ def _rebuild_failed_plugin_info(self) -> None: @staticmethod def _iter_concrete_llm_tools(func_tool: FunctionTool) -> Iterable[FunctionTool]: - """Return concrete function tools that may belong to a plugin. + """Return a registered tool and any tools nested in a handoff. Args: func_tool: A registered function tool, possibly a handoff tool. Returns: - The concrete function tools to inspect for plugin ownership. + Function tools to inspect for plugin ownership. """ + yield func_tool if isinstance(func_tool, HandoffTool): agent = getattr(func_tool, "agent", None) tools = getattr(agent, "tools", None) if agent else None @@ -892,10 +925,10 @@ def _iter_concrete_llm_tools(func_tool: FunctionTool) -> Iterable[FunctionTool]: if isinstance(tool, FunctionTool): yield tool return - yield func_tool - @staticmethod + @classmethod def _is_plugin_llm_tool( + cls, func_tool: FunctionTool, plugin_module_path: str | None, ) -> bool: @@ -909,16 +942,56 @@ def _is_plugin_llm_tool( Whether the tool belongs to the plugin module. """ module_path = getattr(func_tool, "handler_module_path", None) + raw_handler = ( + func_tool.handler.func + if isinstance(func_tool.handler, functools.partial) + else func_tool.handler + ) + raw_handler_module_path = getattr(raw_handler, "__module__", None) return bool( plugin_module_path - and module_path and ( - module_path == plugin_module_path - or module_path.startswith(f"{plugin_module_path}.") + cls._is_plugin_module_path(module_path, plugin_module_path) + or cls._is_plugin_module_path( + raw_handler_module_path, + plugin_module_path, + ) + ) + and not (module_path or "").endswith( + ("astrbot.builtin_stars", "data.plugins") ) - and not module_path.endswith(("astrbot.builtin_stars", "data.plugins")) ) + @classmethod + def _claim_llm_tools_for_plugin( + cls, + plugin_module_path: str, + ) -> None: + """Assign plugin-owned tools to the plugin's canonical Star module. + + Args: + plugin_module_path: Canonical Star module path for the plugin. + + Returns: + None. + """ + module_prefix = cls._plugin_root_module_path(plugin_module_path) + for func_tool in llm_tools.func_list: + for tool in cls._iter_concrete_llm_tools(func_tool): + raw_handler = ( + tool.handler.func + if isinstance(tool.handler, functools.partial) + else tool.handler + ) + if cls._is_plugin_module_path( + getattr(raw_handler, "__module__", None), + module_prefix, + ) or cls._is_plugin_module_path( + tool.handler_module_path, + module_prefix, + ): + tool.handler_module_path = plugin_module_path + @classmethod def _iter_plugin_llm_tools( cls, @@ -1124,13 +1197,16 @@ async def load( # 尝试导入模块 try: - module = await self._import_plugin_with_dependency_recovery( - path=path, - module_str=module_str, - root_dir_name=root_dir_name, - requirements_path=requirements_path, - reserved=reserved, - ) + try: + module = await self._import_plugin_with_dependency_recovery( + path=path, + module_str=module_str, + root_dir_name=root_dir_name, + requirements_path=requirements_path, + reserved=reserved, + ) + finally: + self._claim_llm_tools_for_plugin(path) except Exception as e: error_trace = traceback.format_exc() logger.error(error_trace) @@ -1273,19 +1349,21 @@ async def load( # Apply the same idempotent binding lifecycle to LLM tools. for func_tool in llm_tools.func_list: for ft in self._iter_concrete_llm_tools(func_tool): - if ft.handler and ( - getattr(ft.handler, "__module__", None) - == metadata.module_path - or ( - isinstance(ft.handler, functools.partial) - and ft.handler_module_path == metadata.module_path + raw_handler = ( + ft.handler.func + if isinstance(ft.handler, functools.partial) + else ft.handler + ) + if raw_handler and ( + self._is_plugin_module_path( + getattr(raw_handler, "__module__", None), + metadata.module_path, ) - ): - raw_handler = ( - ft.handler.func - if isinstance(ft.handler, functools.partial) - else ft.handler + or self._is_plugin_llm_tool( + ft, + metadata.module_path, ) + ): ft.handler_module_path = metadata.module_path ft.handler = raw_handler if ( @@ -1430,6 +1508,8 @@ async def load( except Exception: logger.error(traceback.format_exc()) + self.failed_plugin_dict.pop(root_dir_name, None) + except BaseException as e: logger.error(f"----- Failed to load plugin {root_dir_name} -----") errors = traceback.format_exc() @@ -1905,11 +1985,9 @@ async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str) -> Non # llm_tools 中移除该插件的工具函数绑定 to_remove = [] for func_tool in llm_tools.func_list: - mp = func_tool.handler_module_path - if ( - mp - and mp.startswith(plugin_module_path) - and not mp.endswith(("astrbot.builtin_stars", "data.plugins")) + if any( + self._is_plugin_llm_tool(tool, plugin_module_path) + for tool in self._iter_concrete_llm_tools(func_tool) ): to_remove.append(func_tool) for func_tool in to_remove: diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 5cb8747ebb..f88a12b157 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -994,6 +994,299 @@ async def mock_sync_command_configs(): assert plugin_name in plugin_manager_pm.failed_plugin_dict +@pytest.mark.asyncio +async def test_partial_import_rolls_back_only_its_provider_registration( + plugin_manager_pm: PluginManager, + monkeypatch, +): + """A failed import removes only registrations owned by that plugin.""" + from astrbot.core.provider.register import ( + provider_cls_map, + provider_registry, + register_provider_adapter, + ) + + _clear_star_runtime_state() + registry_before = list(provider_registry) + map_before = dict(provider_cls_map) + plugin_name = "partial_provider_plugin" + provider_type = "partial_provider_adapter" + core_provider_type = "partial_provider_core_sentinel" + sibling_provider_type = "partial_provider_sibling_sentinel" + plugin_root = Path(plugin_manager_pm.plugin_store_path).parents[1] + plugin_path = Path(plugin_manager_pm.plugin_store_path) / plugin_name + plugin_path.mkdir(parents=True) + (plugin_path / "metadata.yaml").write_text( + yaml.dump( + { + "name": plugin_name, + "author": "AstrBot Team", + "desc": "Partial provider test plugin", + "version": "1.0.0", + } + ), + encoding="utf-8", + ) + (plugin_path / "main.py").write_text( + f"""from astrbot.core.provider.register import register_provider_adapter + + +@register_provider_adapter("{provider_type}", "Partial test provider") +class PartialProvider: + pass + + +raise RuntimeError("import failed after registration") +""", + encoding="utf-8", + ) + + class CoreSentinel: + pass + + class SiblingSentinel: + pass + + CoreSentinel.__module__ = "astrbot.core.provider.sources.test_sentinel" + SiblingSentinel.__module__ = f"data.plugins.{plugin_name}_extra.main" + register_provider_adapter(core_provider_type, "Core sentinel")(CoreSentinel) + register_provider_adapter(sibling_provider_type, "Sibling sentinel")( + SiblingSentinel + ) + core_metadata = provider_cls_map[core_provider_type] + sibling_metadata = provider_cls_map[sibling_provider_type] + + async def mock_global_get(key, default=None): + del key + return default + + async def mock_sync_command_configs(): + return None + + monkeypatch.syspath_prepend(str(plugin_root)) + monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get) + monkeypatch.setattr( + star_manager_module, + "sync_command_configs", + mock_sync_command_configs, + ) + + try: + success, error = await plugin_manager_pm.load(specified_dir_name=plugin_name) + + assert success is False + assert error is not None + assert "import failed after registration" in error + assert provider_type not in provider_cls_map + assert all(metadata.type != provider_type for metadata in provider_registry) + assert provider_cls_map[core_provider_type] is core_metadata + assert provider_cls_map[sibling_provider_type] is sibling_metadata + assert plugin_name in plugin_manager_pm.failed_plugin_dict + + (plugin_path / "main.py").write_text( + f"""from astrbot.api.star import Star +from astrbot.core.provider.register import register_provider_adapter + + +@register_provider_adapter("{provider_type}", "Partial test provider") +class PartialProvider: + pass + + +class Main(Star): + pass +""", + encoding="utf-8", + ) + + success, error = await plugin_manager_pm.load(specified_dir_name=plugin_name) + + assert success is True + assert error is None + assert sum(md.type == provider_type for md in provider_registry) == 1 + assert plugin_name not in plugin_manager_pm.failed_plugin_dict + finally: + plugin_manager_pm._cleanup_plugin_state(plugin_name) + provider_registry[:] = registry_before + provider_cls_map.clear() + provider_cls_map.update(map_before) + _clear_star_runtime_state() + + +@pytest.mark.asyncio +async def test_dependency_recovery_reimports_partial_decorators_once( + plugin_manager_pm: PluginManager, + monkeypatch, +): + """An in-process dependency retry first rolls back decorator side effects.""" + from astrbot.core.provider.register import provider_cls_map, provider_registry + + _clear_star_runtime_state() + registry_before = list(provider_registry) + map_before = dict(provider_cls_map) + plugin_name = "dependency_recovery_provider_plugin" + provider_type = "dependency_recovery_provider_adapter" + missing_dependency = "astrbot_test_recovered_dependency" + plain_tool_name = "dependency_recovery_plain_tool" + agent_name = "dependency_recovery_agent" + agent_tool_name = "dependency_recovery_agent_tool" + module_path = f"data.plugins.{plugin_name}.main" + plugin_root = Path(plugin_manager_pm.plugin_store_path).parents[1] + plugin_path = Path(plugin_manager_pm.plugin_store_path) / plugin_name + plugin_path.mkdir(parents=True) + (plugin_path / "metadata.yaml").write_text( + yaml.dump( + { + "name": plugin_name, + "author": "AstrBot Team", + "desc": "Provider dependency recovery test plugin", + "version": "1.0.0", + } + ), + encoding="utf-8", + ) + (plugin_path / "requirements.txt").write_text( + f"{missing_dependency}\n", + encoding="utf-8", + ) + (plugin_path / "tools.py").write_text( + f"""from astrbot.core.star.register.star_handler import register_agent, register_llm_tool + + +@register_llm_tool(name="{plain_tool_name}") +async def plain_tool(self): + pass + + +@register_agent(name="{agent_name}", instruction="Recovery helper") +async def helper_agent(self): + pass + + +@helper_agent.llm_tool(name="{agent_tool_name}") +async def agent_tool(self): + pass +""", + encoding="utf-8", + ) + (plugin_path / "main.py").write_text( + f"""from astrbot.api.star import Star +from astrbot.core.provider.register import register_provider_adapter + + +@register_provider_adapter("{provider_type}", "Recovered provider") +class RecoveredProvider: + pass + + +from . import tools +import {missing_dependency} + + +class Main(Star): + pass +""", + encoding="utf-8", + ) + llm_tools = cast(Any, star_manager_module.llm_tools) + original_func_list = llm_tools.func_list + llm_tools.func_list = list(original_func_list) + + async def unrelated_dynamic_tool(): + return None + + unrelated_dynamic_tool.__module__ = "external.runtime_tools" + unrelated_tool = star_manager_module.FunctionTool( + name="unrelated_dynamic_tool", + description="Registered outside the plugin import", + parameters={"type": "object", "properties": {}}, + handler=unrelated_dynamic_tool, + ) + llm_tools.func_list.append(unrelated_tool) + dependency_installed = False + + async def mock_global_get(key, default=None): + del key + return default + + async def mock_sync_command_configs(): + return None + + async def mock_check_plugin_dept_update(*, target_plugin=None): + nonlocal dependency_installed + assert target_plugin == plugin_name + dependency_installed = True + monkeypatch.setitem( + star_manager_module.sys.modules, + missing_dependency, + ModuleType(missing_dependency), + ) + + recovery_state = star_manager_module.ImportDependencyRecoveryState( + star_manager_module.ImportDependencyRecoveryMode.REINSTALL_ON_FAILURE, + MissingRequirementsPlan( + missing_names=frozenset({missing_dependency}), + install_lines=(missing_dependency,), + version_mismatch_names=frozenset({missing_dependency}), + ), + ) + + monkeypatch.syspath_prepend(str(plugin_root)) + monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get) + monkeypatch.setattr( + star_manager_module, + "sync_command_configs", + mock_sync_command_configs, + ) + monkeypatch.setattr( + plugin_manager_pm, + "_resolve_import_dependency_recovery_state", + lambda *args, **kwargs: recovery_state, + ) + monkeypatch.setattr( + plugin_manager_pm, + "_check_plugin_dept_update", + mock_check_plugin_dept_update, + ) + + try: + success, error = await plugin_manager_pm.load(specified_dir_name=plugin_name) + + assert success is True + assert error is None + assert dependency_installed is True + assert sum(md.type == provider_type for md in provider_registry) == 1 + plain_tools = [ + tool for tool in llm_tools.func_list if tool.name == plain_tool_name + ] + handoff_tools = [ + tool + for tool in llm_tools.func_list + if tool.name == f"transfer_to_{agent_name}" + ] + assert len(plain_tools) == 1 + assert len(handoff_tools) == 1 + nested_tools = [ + tool + for tool in handoff_tools[0].agent.tools + if tool.name == agent_tool_name + ] + assert len(nested_tools) == 1 + for tool in [plain_tools[0], handoff_tools[0], nested_tools[0]]: + assert tool.handler_module_path == module_path + assert isinstance(tool.handler, functools.partial) + assert tool.handler.func.__module__ == f"data.plugins.{plugin_name}.tools" + assert unrelated_tool.handler_module_path is None + assert unrelated_tool.active is True + finally: + plugin_manager_pm._cleanup_plugin_state(plugin_name) + provider_registry[:] = registry_before + provider_cls_map.clear() + provider_cls_map.update(map_before) + llm_tools.func_list = original_func_list + _clear_star_runtime_state() + + @pytest.mark.asyncio async def test_ensure_plugin_requirements_reraises_cancelled_error( plugin_manager_pm: PluginManager, local_updater: Path, monkeypatch From 043124d85eb8c4222ac38831de28ec8b6034a51a Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 05:45:08 +0800 Subject: [PATCH 2/2] fix: initialize providers across plugin startup phases --- astrbot/core/core_lifecycle.py | 16 +- astrbot/core/provider/manager.py | 167 ++++++ astrbot/core/star/star_manager.py | 272 ++++++++-- tests/test_plugin_manager.py | 817 +++++++++++++++++++++++++++++- tests/unit/test_core_lifecycle.py | 86 +++- 5 files changed, 1294 insertions(+), 64 deletions(-) diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index db8a6ddc1b..d642025697 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -257,12 +257,18 @@ async def initialize(self) -> None: # 初始化插件管理器 self.plugin_manager = PluginManager(self.star_context, self.astrbot_config) - # 扫描、注册插件、实例化插件类 - await self.plugin_manager.reload() - - # 根据配置实例化各个 Provider self._default_chat_provider_warning_emitted = False - await self.provider_manager.initialize() + # Discover plugin-provided Provider adapters, initialize configured + # Providers, activate plugin instances, then load any adapters registered + # from their initialize() methods. + await self.plugin_manager.initialize_plugins( + before_plugin_activation=self.provider_manager.initialize, + after_plugin_activation=( + self.provider_manager.initialize_pending_registered_providers + ), + ) + + # Check after plugin activation so the warning reflects final state. self._warn_about_unset_default_chat_provider() await self.kb_manager.initialize() diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 718da844ea..d7d05073a1 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -411,6 +411,91 @@ async def _init_mcp_clients_bg() -> None: name="provider-manager:mcp-init", ) + async def initialize_pending_registered_providers(self) -> None: + """Load configured Providers registered during plugin activation. + + This pass is intentionally incremental: an existing Provider instance is + never reloaded or initialized twice. A failure in one newly registered + adapter does not prevent other pending Providers from loading. + + Returns: + None. + """ + loaded_provider_ids = [] + for provider_config in self.providers_config: + provider_id = provider_config.get("id") + provider_type = provider_config.get("type") + if ( + not provider_config.get("enable", False) + or not isinstance(provider_id, str) + or not provider_id + or provider_id in self.inst_map + or not isinstance(provider_type, str) + or provider_type not in provider_cls_map + or provider_config.get("provider_type") == "agent_runner" + ): + continue + + try: + await self.load_provider(provider_config) + except Exception as e: + logger.error(traceback.format_exc()) + logger.error(e) + continue + if provider_id in self.inst_map: + loaded_provider_ids.append(provider_id) + + if not loaded_provider_ids: + return + + selected_provider_id = await sp.get_async( + key="curr_provider", + default=self.default_chat_provider_id, + scope="global", + scope_id="global", + ) + selected_stt_provider_id = await sp.get_async( + key="curr_provider_stt", + default=self.provider_stt_settings.get("provider_id"), + scope="global", + scope_id="global", + ) + selected_tts_provider_id = await sp.get_async( + key="curr_provider_tts", + default=self.provider_tts_settings.get("provider_id"), + scope="global", + scope_id="global", + ) + + selected_provider = ( + self.inst_map.get(selected_provider_id) + if isinstance(selected_provider_id, str) + else None + ) + if isinstance(selected_provider, Provider): + self.curr_provider_inst = selected_provider + + selected_stt_provider = ( + self.inst_map.get(selected_stt_provider_id) + if isinstance(selected_stt_provider_id, str) + else None + ) + if isinstance(selected_stt_provider, STTProvider): + self.curr_stt_provider_inst = selected_stt_provider + + selected_tts_provider = ( + self.inst_map.get(selected_tts_provider_id) + if isinstance(selected_tts_provider_id, str) + else None + ) + if isinstance(selected_tts_provider, TTSProvider): + self.curr_tts_provider_inst = selected_tts_provider + + logger.info( + "Initialized Providers registered during plugin activation: %s", + loaded_provider_ids, + ) + def dynamic_import_provider(self, type: str) -> None: """动态导入提供商适配器模块 @@ -865,6 +950,88 @@ async def reload(self, provider_config: dict) -> None: def get_insts(self): return self.provider_insts + @staticmethod + def _provider_adapter_type(provider_inst: Providers) -> str | None: + """Return the configured adapter type for a Provider instance. + + Args: + provider_inst: Provider instance whose configuration is inspected. + + Returns: + Configured adapter type, or ``None`` when unavailable. + """ + provider_config = getattr(provider_inst, "provider_config", None) + provider_type = ( + provider_config.get("type") if isinstance(provider_config, dict) else None + ) + return provider_type if isinstance(provider_type, str) else None + + def _restore_current_provider_fallbacks(self) -> None: + """Select available current Providers after stale instances are removed. + + Returns: + None. + """ + if self.curr_provider_inst not in self.provider_insts: + self.curr_provider_inst = ( + self.provider_insts[0] if self.provider_insts else None + ) + if self.curr_stt_provider_inst not in self.stt_provider_insts: + self.curr_stt_provider_inst = ( + self.stt_provider_insts[0] if self.stt_provider_insts else None + ) + if self.curr_tts_provider_inst not in self.tts_provider_insts: + self.curr_tts_provider_inst = ( + self.tts_provider_insts[0] if self.tts_provider_insts else None + ) + + async def terminate_unregistered_providers(self) -> list[str]: + """Terminate instances whose adapter registration was rolled back. + + Returns: + IDs of Provider instances removed from the runtime. + """ + stale_provider_ids = [ + provider_id + for provider_id, provider_inst in list(self.inst_map.items()) + if ( + (provider_type := self._provider_adapter_type(provider_inst)) + is not None + and provider_type not in provider_cls_map + ) + ] + + for provider_id in stale_provider_ids: + provider_inst = self.inst_map.get(provider_id) + try: + await self.terminate_provider(provider_id) + except Exception: + logger.exception( + "Failed to terminate rolled-back Provider adapter %s cleanly.", + provider_id, + ) + finally: + if provider_inst is not None: + for provider_instances in ( + self.provider_insts, + self.stt_provider_insts, + self.tts_provider_insts, + self.embedding_provider_insts, + self.rerank_provider_insts, + ): + if provider_inst in provider_instances: + provider_instances.remove(provider_inst) + if provider_inst == self.curr_provider_inst: + self.curr_provider_inst = None + if provider_inst == self.curr_stt_provider_inst: + self.curr_stt_provider_inst = None + if provider_inst == self.curr_tts_provider_inst: + self.curr_tts_provider_inst = None + self.inst_map.pop(provider_id, None) + + self._restore_current_provider_fallbacks() + return stale_provider_ids + async def terminate_provider(self, provider_id: str) -> None: if provider_id in self.inst_map: logger.info( diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 6c5acddf34..c6dbd55394 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -11,7 +11,7 @@ import sys import tempfile import traceback -from collections.abc import Iterable +from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass from enum import Enum, auto from pathlib import Path @@ -220,6 +220,7 @@ def __init__(self, context: Context, config: AstrBotConfig) -> None: """加载失败插件的信息,用于后续可能的热重载""" self.failed_plugin_info = "" + self._last_discovered_plugin_module_paths: set[str] = set() if os.getenv("ASTRBOT_RELOAD", "0") == "1": asyncio.create_task(self._watch_plugins_changes()) @@ -564,6 +565,77 @@ def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | N return metadata + @staticmethod + def _apply_plugin_metadata_yaml( + metadata: StarMetadata, + plugin_dir_path: str, + root_dir_name: str, + ) -> None: + """Apply optional YAML metadata to decorator-registered metadata. + + Args: + metadata: Decorator-registered plugin metadata to update. + plugin_dir_path: Plugin directory containing the metadata file. + root_dir_name: Plugin directory name used in warning messages. + + Returns: + None. + """ + try: + metadata_yaml = PluginManager._load_plugin_metadata( + plugin_path=plugin_dir_path, + ) + if not metadata_yaml: + return + for field_name in ( + "name", + "author", + "desc", + "short_desc", + "version", + "repo", + "display_name", + "support_platforms", + "astrbot_version", + "pages", + "i18n", + ): + setattr(metadata, field_name, getattr(metadata_yaml, field_name)) + except Exception as e: + logger.warning( + f"Failed to load metadata for plugin {root_dir_name}: " + f"{e!s}. Using default metadata.", + ) + + @staticmethod + def _ensure_supported_plugin_version( + metadata: StarMetadata, + ignore_version_check: bool, + ) -> None: + """Reject plugin metadata incompatible with this AstrBot version. + + Args: + metadata: Plugin metadata containing the version constraint. + ignore_version_check: Whether to skip constraint validation. + + Returns: + None. + + Raises: + PluginVersionUnsupportedError: If the version constraint is invalid + or does not include the running AstrBot version. + """ + if ignore_version_check: + return + is_valid, error_message = PluginManager._validate_astrbot_version_specifier( + metadata.astrbot_version, + ) + if not is_valid: + raise PluginVersionUnsupportedError( + error_message + or "The plugin does not support the current AstrBot version." + ) + @staticmethod def _load_plugin_i18n(plugin_path: str) -> dict[str, dict]: plugin_root = Path(plugin_path) @@ -1012,6 +1084,21 @@ def _iter_plugin_llm_tools( if cls._is_plugin_llm_tool(concrete_tool, plugin_module_path): yield concrete_tool + async def _reconcile_startup_provider_rollback(self) -> None: + """Remove live Providers whose startup plugin adapter rolled back. + + Returns: + None. + """ + provider_manager = getattr(self.context, "provider_manager", None) + reconcile = getattr( + provider_manager, + "terminate_unregistered_providers", + None, + ) + if callable(reconcile): + await reconcile() + async def _migrate_legacy_plugin_tool_inactivation_state( self, inactivated_llm_tools: list, @@ -1080,6 +1167,66 @@ async def reload_failed_plugin(self, dir_name): else: return False, error + async def initialize_plugins( + self, + before_plugin_activation: Callable[[], Awaitable[None]], + after_plugin_activation: Callable[[], Awaitable[None]] | None = None, + ) -> tuple[bool, str | None]: + """Initialize plugins around the Provider startup boundary. + + Args: + before_plugin_activation: Callback that initializes configured + Providers after plugin modules register their adapters. + after_plugin_activation: Optional callback that incrementally loads + adapters registered from plugin activation code. + + Returns: + Plugin initialization success and an optional aggregated error message. + """ + async with self._pm_lock: + # Startup normally begins with empty registries. Clear any stale state + # left by an earlier lifecycle in the same process before discovery. + for metadata in list(star_registry): + try: + await self._terminate_plugin(metadata) + except Exception as e: + logger.warning(traceback.format_exc()) + logger.warning( + f"Plugin {metadata.name} did not terminate cleanly: {e!s}. " + "The plugin may not work correctly.", + ) + if metadata.root_dir_name: + self._cleanup_plugin_state( + metadata.root_dir_name, + metadata.reserved, + ) + + star_handlers_registry.clear() + star_map.clear() + star_registry.clear() + + discovery_result = await self.load(discover_only=True) + discovered_module_paths = set( + self._last_discovered_plugin_module_paths, + ) + + # Provider initialization intentionally runs even if one plugin failed + # discovery: valid plugins and their adapters remain usable. + await before_plugin_activation() + + activation_result = await self.load( + activation_module_paths=discovered_module_paths, + ) + if after_plugin_activation is not None: + await after_plugin_activation() + + if discovery_result[0] and activation_result[0]: + return True, None + return ( + False, + self.failed_plugin_info or discovery_result[1] or activation_result[1], + ) + async def reload(self, specified_plugin_name=None): """重新加载插件 @@ -1143,6 +1290,9 @@ async def load( specified_module_path=None, specified_dir_name=None, ignore_version_check: bool = False, + *, + discover_only: bool = False, + activation_module_paths: set[str] | None = None, ): """载入插件。 当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。 @@ -1150,6 +1300,11 @@ async def load( Args: specified_module_path (str, optional): 指定要加载的插件模块路径。例如: "data.plugins.my_plugin.main" specified_dir_name (str, optional): 指定要加载的插件目录名。例如: "my_plugin" + ignore_version_check: Whether to skip the AstrBot version constraint. + discover_only: Import modules and register declarations without + creating or initializing plugin instances. + activation_module_paths: Optional set produced by discovery. When set, + only successfully discovered modules may activate. Returns: tuple: (success, error_message) @@ -1161,6 +1316,9 @@ async def load( inactivated_llm_tools = await sp.global_get("inactivated_llm_tools", []) alter_cmd = await sp.global_get("alter_cmd", {}) + if discover_only: + self._last_discovered_plugin_module_paths = set() + plugin_modules = self._get_plugin_modules() if plugin_modules is None: return False, "未找到任何插件模块" @@ -1192,6 +1350,11 @@ async def load( continue if specified_dir_name and root_dir_name != specified_dir_name: continue + if ( + activation_module_paths is not None + and path not in activation_module_paths + ): + continue logger.info("Loading plugin %s ...", root_dir_name) @@ -1222,6 +1385,47 @@ async def load( ) ) self._cleanup_plugin_state(root_dir_name, reserved) + if activation_module_paths is not None: + await self._reconcile_startup_provider_rollback() + continue + + if discover_only: + metadata = star_map.get(path) + if metadata is not None: + self._apply_plugin_metadata_yaml( + metadata, + plugin_dir_path, + root_dir_name, + ) + else: + classes = self._get_classes(module) + if not classes: + raise Exception( + f"插件 {root_dir_name} 未通过 Star 注册,也没有找到旧版插件类。" + "请确认插件主类继承 astrbot.api.star.Star,或类名以 Plugin 结尾 / 命名为 Main。", + ) + metadata = self._load_plugin_metadata( + plugin_path=plugin_dir_path, + ) + if not metadata: + raise Exception( + f"无法找到插件 {plugin_dir_path} 的元数据。" + ) + + self._ensure_supported_plugin_version( + metadata, + ignore_version_check, + ) + + # Imported declarations must not be callable before their Star + # instance exists. Disabled plugins stay imported so their + # adapter registration keeps its existing startup semantics. + registered_metadata = star_map.get(path) + if registered_metadata is not None: + registered_metadata.activated = False + for func_tool in self._iter_plugin_llm_tools(path): + func_tool.active = False + self._last_discovered_plugin_module_paths.add(path) continue # 检查 _conf_schema.json @@ -1245,40 +1449,15 @@ async def load( # 通过 __init__subclass__ 注册插件 metadata = star_map[path] - try: - # yaml 文件的元数据优先 - metadata_yaml = self._load_plugin_metadata( - plugin_path=plugin_dir_path, - ) - if metadata_yaml: - metadata.name = metadata_yaml.name - metadata.author = metadata_yaml.author - metadata.desc = metadata_yaml.desc - metadata.short_desc = metadata_yaml.short_desc - metadata.version = metadata_yaml.version - metadata.repo = metadata_yaml.repo - metadata.display_name = metadata_yaml.display_name - metadata.support_platforms = metadata_yaml.support_platforms - metadata.astrbot_version = metadata_yaml.astrbot_version - metadata.pages = metadata_yaml.pages - metadata.i18n = metadata_yaml.i18n - except Exception as e: - logger.warning( - f"Failed to load metadata for plugin {root_dir_name}: " - f"{e!s}. Using default metadata.", - ) - - if not ignore_version_check: - is_valid, error_message = ( - self._validate_astrbot_version_specifier( - metadata.astrbot_version, - ) - ) - if not is_valid: - raise PluginVersionUnsupportedError( - error_message - or "The plugin does not support the current AstrBot version." - ) + self._apply_plugin_metadata_yaml( + metadata, + plugin_dir_path, + root_dir_name, + ) + self._ensure_supported_plugin_version( + metadata, + ignore_version_check, + ) logger.info(metadata) metadata.config = plugin_config @@ -1420,17 +1599,10 @@ async def load( if not metadata: raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。") - if not ignore_version_check: - is_valid, error_message = ( - self._validate_astrbot_version_specifier( - metadata.astrbot_version, - ) - ) - if not is_valid: - raise PluginVersionUnsupportedError( - error_message - or "The plugin does not support the current AstrBot version." - ) + self._ensure_supported_plugin_version( + metadata, + ignore_version_check, + ) metadata.star_cls = obj metadata.config = plugin_config @@ -1527,6 +1699,14 @@ async def load( ) ) self._cleanup_plugin_state(root_dir_name, reserved) + if activation_module_paths is not None: + await self._reconcile_startup_provider_rollback() + + if discover_only: + self._rebuild_failed_plugin_info() + if has_load_error: + return False, self.failed_plugin_info + return True, None if not specified_module_path and not specified_dir_name: inactivated_llm_tools = ( diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index f88a12b157..e1f6d973f7 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -3,7 +3,7 @@ import json import os from pathlib import Path -from types import ModuleType +from types import ModuleType, SimpleNamespace from typing import Any, cast import pytest @@ -77,6 +77,67 @@ def _write_local_test_plugin(plugin_path: Path, repo_url: str, version: str = "1 f.write(" def __init__(self, context: Context): ...\n") +def _write_startup_test_plugin( + plugin_manager: PluginManager, + plugin_name: str, + source: str, + *, + astrbot_version: str | None = None, +) -> Path: + """Write one isolated plugin used by startup lifecycle tests. + + Args: + plugin_manager: Plugin manager whose test store receives the plugin. + plugin_name: Importable plugin directory name. + source: Python source for the plugin's main module. + astrbot_version: Optional AstrBot compatibility constraint. + + Returns: + Path to the created plugin directory. + """ + plugin_path = Path(plugin_manager.plugin_store_path) / plugin_name + plugin_path.mkdir(parents=True) + metadata = { + "name": plugin_name, + "author": "AstrBot Team", + "desc": "Startup lifecycle test plugin", + "version": "1.0.0", + } + if astrbot_version is not None: + metadata["astrbot_version"] = astrbot_version + (plugin_path / "metadata.yaml").write_text( + yaml.dump(metadata), + encoding="utf-8", + ) + (plugin_path / "main.py").write_text(source, encoding="utf-8") + return plugin_path + + +def _mock_plugin_preferences(monkeypatch, preferences: dict) -> None: + """Replace persistent plugin preferences and command synchronization. + + Args: + monkeypatch: Pytest monkeypatch fixture. + preferences: Values returned for persistent preference keys. + + Returns: + None. + """ + + async def mock_global_get(key, default=None): + return preferences.get(key, default) + + async def mock_sync_command_configs(): + return None + + monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get) + monkeypatch.setattr( + star_manager_module, + "sync_command_configs", + mock_sync_command_configs, + ) + + def _write_requirements(plugin_path: Path): """Creates a requirements.txt file.""" with open(plugin_path / "requirements.txt", "w", encoding="utf-8") as f: @@ -994,6 +1055,760 @@ async def mock_sync_command_configs(): assert plugin_name in plugin_manager_pm.failed_plugin_dict +@pytest.mark.asyncio +async def test_startup_loads_plugin_provider_before_star_initialization( + plugin_manager_pm: PluginManager, + monkeypatch, +): + """A plugin Star can query its configured Provider during initialize().""" + from astrbot.core.provider.manager import ProviderManager + from astrbot.core.provider.register import provider_cls_map, provider_registry + + _clear_star_runtime_state() + registry_before = list(provider_registry) + map_before = dict(provider_cls_map) + plugin_name = "startup_provider_plugin" + consumer_plugin_name = "startup_provider_consumer_plugin" + module_path = f"data.plugins.{plugin_name}.main" + provider_type = "startup_plugin_provider" + provider_id = "provider-target" + _write_startup_test_plugin( + plugin_manager_pm, + plugin_name, + f"""from astrbot.api.star import Star +from astrbot.core.provider.provider import Provider +from astrbot.core.provider.register import register_provider_adapter + + +@register_provider_adapter("{provider_type}", "Startup test provider") +class StartupProvider(Provider): + def get_current_key(self): + return "" + + def set_key(self, key): + pass + + async def get_models(self): + return [] + + async def text_chat(self, *args, **kwargs): + raise NotImplementedError + + +class Main(Star): + pass +""", + ) + _write_startup_test_plugin( + plugin_manager_pm, + consumer_plugin_name, + f"""from astrbot.api.star import Star + + +class Main(Star): + async def initialize(self): + self.context.provider_seen_during_plugin_initialize = ( + self.context.get_provider_by_id("{provider_id}") + ) +""", + ) + preferences = { + "inactivated_plugins": [], + "inactivated_llm_tools": [], + "alter_cmd": {}, + star_manager_module.PLUGIN_TOOL_STATE_MIGRATION_KEY: True, + } + _mock_plugin_preferences(monkeypatch, preferences) + monkeypatch.syspath_prepend( + str(Path(plugin_manager_pm.plugin_store_path).parents[1]) + ) + monkeypatch.setattr( + plugin_manager_pm, + "_get_plugin_modules", + lambda: [ + {"pname": plugin_name, "module": "main"}, + {"pname": consumer_plugin_name, "module": "main"}, + ], + ) + + provider_manager = ProviderManager.__new__(ProviderManager) + provider_manager.provider_sources_config = [] + provider_manager.provider_settings = {} + provider_manager.default_chat_provider_id = provider_id + provider_manager.provider_insts = [] + provider_manager.stt_provider_insts = [] + provider_manager.tts_provider_insts = [] + provider_manager.embedding_provider_insts = [] + provider_manager.rerank_provider_insts = [] + provider_manager.inst_map = {} + provider_manager.curr_provider_inst = None + provider_manager.curr_stt_provider_inst = None + provider_manager.curr_tts_provider_inst = None + + context = cast(Any, plugin_manager_pm.context) + context.provider_manager = provider_manager + context.get_provider_by_id = provider_manager.inst_map.get + + async def initialize_provider(): + assert provider_type in provider_cls_map + metadata = star_manager_module.star_map[module_path] + assert metadata.activated is False + assert metadata.star_cls is None + await provider_manager.load_provider( + { + "id": provider_id, + "type": provider_type, + "provider_type": "chat_completion", + "enable": True, + "key": [], + } + ) + + try: + success, error = await plugin_manager_pm.initialize_plugins( + before_plugin_activation=initialize_provider, + ) + + assert success is True + assert error is None + target_provider = provider_manager.inst_map[provider_id] + assert context.provider_seen_during_plugin_initialize is target_provider + finally: + plugin_manager_pm._cleanup_plugin_state(plugin_name) + plugin_manager_pm._cleanup_plugin_state(consumer_plugin_name) + provider_registry[:] = registry_before + provider_cls_map.clear() + provider_cls_map.update(map_before) + _clear_star_runtime_state() + + +@pytest.mark.asyncio +async def test_startup_loads_providers_registered_during_star_initialization( + plugin_manager_pm: PluginManager, + monkeypatch, +): + """Late Provider registration is loaded incrementally and idempotently.""" + from astrbot.core.provider import manager as provider_manager_module + from astrbot.core.provider.manager import ProviderManager + from astrbot.core.provider.register import provider_cls_map, provider_registry + + _clear_star_runtime_state() + registry_before = list(provider_registry) + map_before = dict(provider_cls_map) + plugin_name = "startup_late_provider_plugin" + module_path = f"data.plugins.{plugin_name}.main" + failing_provider_type = "startup_late_failing_provider" + provider_type = "startup_late_provider" + failing_provider_id = "provider-late-failing" + provider_id = "provider-late-target" + fallback_provider_id = "provider-late-fallback" + _write_startup_test_plugin( + plugin_manager_pm, + plugin_name, + f"""from astrbot.api.star import Star +from astrbot.core.provider.provider import Provider +from astrbot.core.provider.register import register_provider_adapter + + +class FailingProvider(Provider): + creation_attempts = 0 + + def __init__(self, provider_config, provider_settings): + type(self).creation_attempts += 1 + raise RuntimeError("provider initialization failed") + + def get_current_key(self): + return "" + + def set_key(self, key): + pass + + async def get_models(self): + return [] + + async def text_chat(self, *args, **kwargs): + raise NotImplementedError + + +class LateProvider(Provider): + creation_count = 0 + initialization_count = 0 + + def __init__(self, provider_config, provider_settings): + super().__init__(provider_config, provider_settings) + type(self).creation_count += 1 + + async def initialize(self): + type(self).initialization_count += 1 + + def get_current_key(self): + return "" + + def set_key(self, key): + pass + + async def get_models(self): + return [] + + async def text_chat(self, *args, **kwargs): + raise NotImplementedError + + +class Main(Star): + async def initialize(self): + register_provider_adapter( + "{failing_provider_type}", + "Failing late Provider", + )(FailingProvider) + register_provider_adapter( + "{provider_type}", + "Late Provider", + )(LateProvider) + self.context.late_provider_registration_completed = True +""", + ) + preferences = { + "inactivated_plugins": [], + "inactivated_llm_tools": [], + "alter_cmd": {}, + star_manager_module.PLUGIN_TOOL_STATE_MIGRATION_KEY: True, + } + _mock_plugin_preferences(monkeypatch, preferences) + monkeypatch.syspath_prepend( + str(Path(plugin_manager_pm.plugin_store_path).parents[1]) + ) + monkeypatch.setattr( + plugin_manager_pm, + "_get_plugin_modules", + lambda: [{"pname": plugin_name, "module": "main"}], + ) + + async def get_provider_selection(scope, scope_id, key, default=None): + assert (scope, scope_id) == ("global", "global") + if key == "curr_provider": + return provider_id + return default + + monkeypatch.setattr( + provider_manager_module.sp, + "get_async", + get_provider_selection, + ) + + fallback_provider = SimpleNamespace( + provider_config={"id": fallback_provider_id, "type": "test_fallback"}, + ) + provider_manager = ProviderManager.__new__(ProviderManager) + provider_manager.providers_config = [ + { + "id": failing_provider_id, + "type": failing_provider_type, + "provider_type": "chat_completion", + "enable": True, + "key": [], + }, + { + "id": provider_id, + "type": provider_type, + "provider_type": "chat_completion", + "enable": True, + "key": [], + }, + ] + provider_manager.provider_sources_config = [] + provider_manager.provider_settings = {} + provider_manager.provider_stt_settings = {} + provider_manager.provider_tts_settings = {} + provider_manager.default_chat_provider_id = "" + provider_manager.provider_insts = [fallback_provider] + provider_manager.stt_provider_insts = [] + provider_manager.tts_provider_insts = [] + provider_manager.embedding_provider_insts = [] + provider_manager.rerank_provider_insts = [] + provider_manager.inst_map = {fallback_provider_id: fallback_provider} + provider_manager.curr_provider_inst = fallback_provider + provider_manager.curr_stt_provider_inst = None + provider_manager.curr_tts_provider_inst = None + + context = cast(Any, plugin_manager_pm.context) + context.provider_manager = provider_manager + callback_order = [] + + async def initialize_providers_before_activation(): + callback_order.append("before") + assert failing_provider_type not in provider_cls_map + assert provider_type not in provider_cls_map + assert provider_id not in provider_manager.inst_map + assert star_manager_module.star_map[module_path].activated is False + + async def initialize_providers_after_activation(): + callback_order.append("after") + assert context.late_provider_registration_completed is True + assert failing_provider_type in provider_cls_map + assert provider_type in provider_cls_map + await provider_manager.initialize_pending_registered_providers() + + try: + success, error = await plugin_manager_pm.initialize_plugins( + before_plugin_activation=initialize_providers_before_activation, + after_plugin_activation=initialize_providers_after_activation, + ) + + assert success is True + assert error is None + assert callback_order == ["before", "after"] + assert failing_provider_id not in provider_manager.inst_map + provider = provider_manager.inst_map[provider_id] + assert provider_manager.curr_provider_inst is provider + assert provider_manager.provider_insts == [fallback_provider, provider] + + failing_provider_cls = provider_cls_map[failing_provider_type].cls_type + provider_cls = provider_cls_map[provider_type].cls_type + assert failing_provider_cls.creation_attempts == 1 + assert provider_cls.creation_count == 1 + assert provider_cls.initialization_count == 1 + + await provider_manager.initialize_pending_registered_providers() + + assert provider_manager.inst_map[provider_id] is provider + assert provider_manager.provider_insts == [fallback_provider, provider] + assert failing_provider_cls.creation_attempts == 2 + assert provider_cls.creation_count == 1 + assert provider_cls.initialization_count == 1 + finally: + plugin_manager_pm._cleanup_plugin_state(plugin_name) + provider_registry[:] = registry_before + provider_cls_map.clear() + provider_cls_map.update(map_before) + _clear_star_runtime_state() + + +@pytest.mark.asyncio +async def test_startup_isolates_incompatible_plugin_before_provider_callback( + plugin_manager_pm: PluginManager, + monkeypatch, +): + """Version rejection rolls back one plugin without blocking later activation.""" + from astrbot.core.provider.register import provider_cls_map, provider_registry + + _clear_star_runtime_state() + registry_before = list(provider_registry) + map_before = dict(provider_cls_map) + bad_plugin = "startup_incompatible_plugin" + good_plugin = "startup_healthy_plugin" + bad_type = "startup_incompatible_provider" + good_type = "startup_healthy_provider" + bad_module = f"data.plugins.{bad_plugin}.main" + good_module = f"data.plugins.{good_plugin}.main" + _write_startup_test_plugin( + plugin_manager_pm, + bad_plugin, + f"""from astrbot.api.star import Star +from astrbot.core.provider.register import register_provider_adapter + + +@register_provider_adapter("{bad_type}", "Incompatible provider") +class IncompatibleProvider: + pass + + +class Main(Star): + async def initialize(self): + self.context.incompatible_plugin_initialized = True +""", + astrbot_version=">=9999", + ) + _write_startup_test_plugin( + plugin_manager_pm, + good_plugin, + f"""from astrbot.api.star import Star +from astrbot.core.provider.register import register_provider_adapter + + +@register_provider_adapter("{good_type}", "Healthy provider") +class HealthyProvider: + pass + + +class Main(Star): + async def initialize(self): + self.context.healthy_plugin_initialized = True +""", + ) + preferences = { + "inactivated_plugins": [], + "inactivated_llm_tools": [], + "alter_cmd": {}, + star_manager_module.PLUGIN_TOOL_STATE_MIGRATION_KEY: True, + } + _mock_plugin_preferences(monkeypatch, preferences) + monkeypatch.syspath_prepend( + str(Path(plugin_manager_pm.plugin_store_path).parents[1]) + ) + monkeypatch.setattr( + plugin_manager_pm, + "_get_plugin_modules", + lambda: [ + {"pname": bad_plugin, "module": "main"}, + {"pname": good_plugin, "module": "main"}, + ], + ) + callback_called = False + + async def initialize_providers(): + nonlocal callback_called + callback_called = True + assert bad_type not in provider_cls_map + assert bad_module not in star_manager_module.star_map + assert good_type in provider_cls_map + assert star_manager_module.star_map[good_module].activated is False + + try: + success, error = await plugin_manager_pm.initialize_plugins( + before_plugin_activation=initialize_providers, + ) + + assert success is False + assert error is not None + assert "does not satisfy plugin astrbot_version" in error + assert callback_called is True + assert not getattr( + plugin_manager_pm.context, + "incompatible_plugin_initialized", + False, + ) + assert plugin_manager_pm.context.healthy_plugin_initialized is True + assert good_module in star_manager_module.star_map + assert star_manager_module.star_map[good_module].activated is True + assert bad_plugin in plugin_manager_pm.failed_plugin_dict + finally: + plugin_manager_pm._cleanup_plugin_state(bad_plugin) + plugin_manager_pm._cleanup_plugin_state(good_plugin) + provider_registry[:] = registry_before + provider_cls_map.clear() + provider_cls_map.update(map_before) + _clear_star_runtime_state() + + +@pytest.mark.asyncio +async def test_startup_keeps_disabled_plugin_adapter_without_activating_star( + plugin_manager_pm: PluginManager, + monkeypatch, +): + """Disabled plugins preserve adapter registration but never initialize a Star.""" + from astrbot.core.provider.register import provider_cls_map, provider_registry + + _clear_star_runtime_state() + registry_before = list(provider_registry) + map_before = dict(provider_cls_map) + plugin_name = "startup_disabled_provider_plugin" + module_path = f"data.plugins.{plugin_name}.main" + provider_type = "startup_disabled_provider" + _write_startup_test_plugin( + plugin_manager_pm, + plugin_name, + f"""from astrbot.api.star import Star +from astrbot.core.provider.register import register_provider_adapter + + +@register_provider_adapter("{provider_type}", "Disabled provider") +class DisabledProvider: + pass + + +class Main(Star): + async def initialize(self): + self.context.disabled_plugin_initialized = True +""", + ) + preferences = { + "inactivated_plugins": [module_path], + "inactivated_llm_tools": [], + "alter_cmd": {}, + star_manager_module.PLUGIN_TOOL_STATE_MIGRATION_KEY: True, + } + _mock_plugin_preferences(monkeypatch, preferences) + monkeypatch.syspath_prepend( + str(Path(plugin_manager_pm.plugin_store_path).parents[1]) + ) + monkeypatch.setattr( + plugin_manager_pm, + "_get_plugin_modules", + lambda: [{"pname": plugin_name, "module": "main"}], + ) + + async def initialize_providers(): + assert provider_type in provider_cls_map + assert star_manager_module.star_map[module_path].activated is False + + try: + success, error = await plugin_manager_pm.initialize_plugins( + before_plugin_activation=initialize_providers, + ) + + assert success is True + assert error is None + assert provider_type in provider_cls_map + metadata = star_manager_module.star_map[module_path] + assert metadata.activated is False + assert metadata.star_cls is None + assert not getattr( + plugin_manager_pm.context, + "disabled_plugin_initialized", + False, + ) + finally: + plugin_manager_pm._cleanup_plugin_state(plugin_name) + provider_registry[:] = registry_before + provider_cls_map.clear() + provider_cls_map.update(map_before) + _clear_star_runtime_state() + + +@pytest.mark.asyncio +async def test_startup_activation_failure_reconciles_live_plugin_provider( + plugin_manager_pm: PluginManager, + monkeypatch, +): + """A failed Star activation removes its adapter instance and restores fallback.""" + from astrbot.core.provider.manager import ProviderManager + from astrbot.core.provider.register import ( + provider_cls_map, + provider_registry, + register_provider_adapter, + ) + + _clear_star_runtime_state() + registry_before = list(provider_registry) + map_before = dict(provider_cls_map) + plugin_name = "startup_failing_activation_plugin" + consumer_plugin_name = "startup_after_failed_activation_plugin" + consumer_module = f"data.plugins.{consumer_plugin_name}.main" + provider_type = "startup_failing_activation_provider" + fallback_type = "startup_activation_fallback_provider" + provider_id = "startup-failing-provider" + fallback_id = "startup-fallback-provider" + + class FallbackProvider: + pass + + FallbackProvider.__module__ = "astrbot.core.provider.sources.test_fallback" + register_provider_adapter(fallback_type, "Activation fallback")(FallbackProvider) + _write_startup_test_plugin( + plugin_manager_pm, + plugin_name, + f"""from astrbot.api.star import Star +from astrbot.core.provider.register import register_provider_adapter + + +@register_provider_adapter("{provider_type}", "Failing activation provider") +class FailingProvider: + def __init__(self): + self.provider_config = {{"id": "{provider_id}", "type": "{provider_type}"}} + self.terminated = False + + async def terminate(self): + self.terminated = True + raise RuntimeError("provider terminate failed") + + +class Main(Star): + async def initialize(self): + raise RuntimeError("plugin initialize failed") +""", + ) + _write_startup_test_plugin( + plugin_manager_pm, + consumer_plugin_name, + f"""from astrbot.api.star import Star + + +class Main(Star): + async def initialize(self): + self.context.failed_provider_seen_by_later_plugin = ( + self.context.provider_manager.inst_map.get("{provider_id}") + ) +""", + ) + preferences = { + "inactivated_plugins": [], + "inactivated_llm_tools": [], + "alter_cmd": {}, + star_manager_module.PLUGIN_TOOL_STATE_MIGRATION_KEY: True, + } + _mock_plugin_preferences(monkeypatch, preferences) + monkeypatch.syspath_prepend( + str(Path(plugin_manager_pm.plugin_store_path).parents[1]) + ) + monkeypatch.setattr( + plugin_manager_pm, + "_get_plugin_modules", + lambda: [ + {"pname": plugin_name, "module": "main"}, + {"pname": consumer_plugin_name, "module": "main"}, + ], + ) + + fallback_provider = SimpleNamespace( + provider_config={"id": fallback_id, "type": fallback_type}, + ) + provider_manager = ProviderManager.__new__(ProviderManager) + provider_manager.provider_insts = [fallback_provider] + provider_manager.stt_provider_insts = [] + provider_manager.tts_provider_insts = [] + provider_manager.embedding_provider_insts = [] + provider_manager.rerank_provider_insts = [] + provider_manager.inst_map = {fallback_id: fallback_provider} + provider_manager.curr_provider_inst = fallback_provider + provider_manager.curr_stt_provider_inst = None + provider_manager.curr_tts_provider_inst = None + plugin_manager_pm.context.provider_manager = provider_manager + provider_inst = None + after_activation_callback_called = False + + async def initialize_providers(): + nonlocal provider_inst + provider_inst = provider_cls_map[provider_type].cls_type() + provider_manager.inst_map[provider_id] = provider_inst + provider_manager.provider_insts.append(provider_inst) + provider_manager.curr_provider_inst = provider_inst + + async def initialize_pending_providers(): + nonlocal after_activation_callback_called + after_activation_callback_called = True + assert provider_type not in provider_cls_map + assert provider_id not in provider_manager.inst_map + assert star_manager_module.star_map[consumer_module].activated is True + + try: + success, error = await plugin_manager_pm.initialize_plugins( + before_plugin_activation=initialize_providers, + after_plugin_activation=initialize_pending_providers, + ) + + assert success is False + assert error is not None + assert "plugin initialize failed" in error + assert provider_type not in provider_cls_map + assert provider_inst is not None + assert provider_inst.terminated is True + assert provider_id not in provider_manager.inst_map + assert provider_inst not in provider_manager.provider_insts + assert provider_manager.provider_insts == [fallback_provider] + assert provider_manager.curr_provider_inst is fallback_provider + assert plugin_manager_pm.context.failed_provider_seen_by_later_plugin is None + assert star_manager_module.star_map[consumer_module].activated is True + assert after_activation_callback_called is True + finally: + plugin_manager_pm._cleanup_plugin_state(plugin_name) + plugin_manager_pm._cleanup_plugin_state(consumer_plugin_name) + provider_registry[:] = registry_before + provider_cls_map.clear() + provider_cls_map.update(map_before) + _clear_star_runtime_state() + + +@pytest.mark.asyncio +async def test_startup_keeps_plugin_tools_inactive_until_star_activation( + plugin_manager_pm: PluginManager, + monkeypatch, +): + """Plain and handoff tools become callable only after their Star is bound.""" + _clear_star_runtime_state() + plugin_name = "startup_tool_plugin" + module_path = f"data.plugins.{plugin_name}.main" + plain_tool_name = "startup_plain_tool" + agent_name = "startup_helper" + handoff_tool_name = f"transfer_to_{agent_name}" + agent_tool_name = "startup_agent_tool" + plugin_path = _write_startup_test_plugin( + plugin_manager_pm, + plugin_name, + "from astrbot.api.star import Star\nfrom . import tools\n\n\nclass Main(Star):\n pass\n", + ) + (plugin_path / "tools.py").write_text( + f'''from astrbot.core.star.register.star_handler import register_agent, register_llm_tool + + +@register_llm_tool(name="{plain_tool_name}") +async def plain_tool(self): + """A plain plugin tool.""" + + +@register_agent(name="{agent_name}", instruction="Help with startup tests") +async def helper_agent(self): + pass + + +@helper_agent.llm_tool(name="{agent_tool_name}") +async def agent_tool(self): + """A nested plugin tool.""" +''', + encoding="utf-8", + ) + preferences = { + "inactivated_plugins": [], + "inactivated_llm_tools": [plain_tool_name, agent_tool_name], + "alter_cmd": {}, + star_manager_module.PLUGIN_TOOL_STATE_MIGRATION_KEY: True, + } + _mock_plugin_preferences(monkeypatch, preferences) + monkeypatch.syspath_prepend( + str(Path(plugin_manager_pm.plugin_store_path).parents[1]) + ) + monkeypatch.setattr( + plugin_manager_pm, + "_get_plugin_modules", + lambda: [{"pname": plugin_name, "module": "main"}], + ) + llm_tools = cast(Any, star_manager_module.llm_tools) + original_func_list = llm_tools.func_list + llm_tools.func_list = list(original_func_list) + discovered_tools: dict[str, Any] = {} + + async def initialize_providers(): + for func_tool in llm_tools.func_list: + if func_tool.name == plain_tool_name: + discovered_tools[plain_tool_name] = func_tool + if func_tool.name == handoff_tool_name: + discovered_tools[handoff_tool_name] = func_tool + discovered_tools[agent_tool_name] = next( + tool + for tool in func_tool.agent.tools + if tool.name == agent_tool_name + ) + + assert set(discovered_tools) == { + plain_tool_name, + handoff_tool_name, + agent_tool_name, + } + for tool in discovered_tools.values(): + assert tool.handler_module_path == module_path + assert tool.active is False + + try: + success, error = await plugin_manager_pm.initialize_plugins( + before_plugin_activation=initialize_providers, + ) + + assert success is True + assert error is None + assert discovered_tools[plain_tool_name].active is False + assert discovered_tools[handoff_tool_name].active is True + assert discovered_tools[agent_tool_name].active is False + metadata = star_manager_module.star_map[module_path] + assert metadata.activated is True + for tool in discovered_tools.values(): + assert tool.handler_module_path == module_path + assert isinstance(tool.handler, functools.partial) + assert tool.handler.args == (metadata.star_cls,) + finally: + plugin_manager_pm._cleanup_plugin_state(plugin_name) + llm_tools.func_list = original_func_list + _clear_star_runtime_state() + + @pytest.mark.asyncio async def test_partial_import_rolls_back_only_its_provider_registration( plugin_manager_pm: PluginManager, diff --git a/tests/unit/test_core_lifecycle.py b/tests/unit/test_core_lifecycle.py index 058f869fd6..d3ee5eba5e 100644 --- a/tests/unit/test_core_lifecycle.py +++ b/tests/unit/test_core_lifecycle.py @@ -9,6 +9,7 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.log import LogBroker +from astrbot.core.star.context import Context @pytest.fixture @@ -407,7 +408,34 @@ async def test_initialize_sets_up_all_components( mock_persona_mgr.initialize = AsyncMock() mock_provider_manager = MagicMock() - mock_provider_manager.initialize = AsyncMock() + mock_provider_manager.inst_map = {} + mock_provider_manager.provider_insts = [] + mock_provider_manager.default_chat_provider_id = "" + mock_provider_manager.curr_provider_inst = None + + target_provider = MagicMock() + target_provider.provider_config = {"id": "provider-target"} + fallback_provider = MagicMock() + fallback_provider.provider_config = {"id": "provider-fallback"} + plugin_modules_discovered = False + plugin_activation_completed = False + + async def initialize_providers(): + assert plugin_modules_discovered is True + mock_provider_manager.inst_map["provider-target"] = target_provider + mock_provider_manager.provider_insts.extend( + [target_provider, fallback_provider] + ) + mock_provider_manager.curr_provider_inst = target_provider + + mock_provider_manager.initialize = AsyncMock(side_effect=initialize_providers) + + async def initialize_pending_providers(): + assert plugin_activation_completed is True + + mock_provider_manager.initialize_pending_registered_providers = AsyncMock( + side_effect=initialize_pending_providers + ) mock_platform_manager = MagicMock() mock_platform_manager.initialize = AsyncMock() @@ -421,11 +449,35 @@ async def test_initialize_sets_up_all_components( mock_cron_manager = MagicMock() - mock_star_context = MagicMock() - mock_star_context._register_tasks = [] - mock_plugin_manager = MagicMock() - mock_plugin_manager.reload = AsyncMock() + + async def initialize_plugins( + *, + before_plugin_activation, + after_plugin_activation, + ): + nonlocal plugin_activation_completed, plugin_modules_discovered + assert before_plugin_activation is mock_provider_manager.initialize + assert ( + after_plugin_activation + is mock_provider_manager.initialize_pending_registered_providers + ) + plugin_modules_discovered = True + await before_plugin_activation() + assert isinstance(lifecycle.star_context, Context) + assert ( + lifecycle.star_context.get_provider_by_id("provider-target") + is target_provider + ) + mock_provider_manager.default_chat_provider_id = "provider-target" + plugin_activation_completed = True + await after_plugin_activation() + # One isolated plugin may fail without preventing Core startup. + return False, "one plugin failed" + + mock_plugin_manager.initialize_plugins = AsyncMock( + side_effect=initialize_plugins + ) mock_pipeline_scheduler = MagicMock() mock_pipeline_scheduler.initialize = AsyncMock() @@ -473,9 +525,6 @@ async def test_initialize_sets_up_all_components( "astrbot.core.core_lifecycle.CronJobManager", return_value=mock_cron_manager, ), - patch( - "astrbot.core.core_lifecycle.Context", return_value=mock_star_context - ), patch( "astrbot.core.core_lifecycle.PluginManager", return_value=mock_plugin_manager, @@ -514,16 +563,26 @@ async def test_initialize_sets_up_all_components( # Verify provider manager initialized mock_provider_manager.initialize.assert_awaited_once() + mock_provider_manager.initialize_pending_registered_providers.assert_awaited_once() # Verify platform manager initialized mock_platform_manager.initialize.assert_awaited_once() - # Verify plugin manager reloaded - mock_plugin_manager.reload.assert_awaited_once() + # Verify the startup-only two-phase plugin lifecycle was used. + mock_plugin_manager.initialize_plugins.assert_awaited_once_with( + before_plugin_activation=mock_provider_manager.initialize, + after_plugin_activation=( + mock_provider_manager.initialize_pending_registered_providers + ), + ) # Verify knowledge base manager initialized mock_kb_manager.initialize.assert_awaited_once() + # Warning state is evaluated only after plugin activation mutates the + # effective Provider selection. + assert lifecycle._default_chat_provider_warning_emitted is False + # Verify pipeline scheduler loaded assert lifecycle.pipeline_scheduler_mapping is not None @@ -565,7 +624,10 @@ async def test_initialize_handles_migration_failure( ), patch( "astrbot.core.core_lifecycle.ProviderManager", - return_value=MagicMock(initialize=AsyncMock()), + return_value=MagicMock( + initialize=AsyncMock(), + initialize_pending_registered_providers=AsyncMock(), + ), ), patch( "astrbot.core.core_lifecycle.PlatformManager", @@ -593,7 +655,7 @@ async def test_initialize_handles_migration_failure( ), patch( "astrbot.core.core_lifecycle.PluginManager", - return_value=MagicMock(reload=AsyncMock()), + return_value=MagicMock(initialize_plugins=AsyncMock()), ), patch( "astrbot.core.core_lifecycle.PipelineScheduler",