feat: Enhance plugin runtime configuration and hook management

- Added `inactive_plugins` field to `RunnerReadyPayload` and `ReloadPluginResultPayload` to track plugins that are not activated due to being disabled or unmet dependencies.
- Introduced `InspectPluginConfigPayload` and `InspectPluginConfigResultPayload` for inspecting plugin configuration metadata.
- Implemented `PluginActivationStatus` enum to better represent plugin activation states.
- Updated `_activate_plugin` method to return activation status and handle inactive plugins accordingly.
- Added hooks for send service to allow modification of messages before and after sending.
- Created new runtime routes for listing hook specifications in the WebUI.
- Refactored plugin configuration handling to utilize runtime inspection for better accuracy and flexibility.
- Enhanced error handling and logging for plugin configuration operations.
This commit is contained in:
DrSmoothl
2026-04-02 21:16:31 +08:00
parent 56f7184c4d
commit 7d0d429640
22 changed files with 2698 additions and 1120 deletions

View File

@@ -9,8 +9,10 @@
6. 转发插件的能力调用到 Host
"""
from collections.abc import Mapping
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, List, Mapping, Optional, Protocol, Set, Tuple, cast
from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast
import asyncio
import contextlib
@@ -39,6 +41,8 @@ from src.plugin_runtime.protocol.envelope import (
ConfigUpdatedPayload,
Envelope,
HealthPayload,
InspectPluginConfigPayload,
InspectPluginConfigResultPayload,
InvokePayload,
InvokeResultPayload,
RegisterPluginPayload,
@@ -141,6 +145,14 @@ class _ConfigAwarePlugin(Protocol):
...
class PluginActivationStatus(str, Enum):
"""描述插件激活结果。"""
LOADED = "loaded"
INACTIVE = "inactive"
FAILED = "failed"
def _install_shutdown_signal_handlers(
mark_runner_shutting_down: Callable[[], None],
loop: Optional[asyncio.AbstractEventLoop] = None,
@@ -236,13 +248,43 @@ class PluginRunner:
# 4. 注入 PluginContext + 调用 on_load 生命周期钩子
failed_plugins: Set[str] = set(self._loader.failed_plugins.keys())
inactive_plugins: Set[str] = set()
available_plugin_versions: Dict[str, str] = dict(self._external_available_plugins)
for meta in plugins:
ok = await self._activate_plugin(meta)
if not ok:
unsatisfied_dependencies = [
dependency.id
for dependency in meta.manifest.plugin_dependencies
if dependency.id not in available_plugin_versions
or not self._loader.manifest_validator.is_plugin_dependency_satisfied(
dependency,
available_plugin_versions[dependency.id],
)
]
if unsatisfied_dependencies:
if any(dependency_id in inactive_plugins for dependency_id in unsatisfied_dependencies):
logger.info(
f"插件 {meta.plugin_id} 依赖的插件当前未激活,跳过本次启动: {', '.join(unsatisfied_dependencies)}"
)
inactive_plugins.add(meta.plugin_id)
continue
failed_plugins.add(meta.plugin_id)
continue
successful_plugins = [meta.plugin_id for meta in plugins if meta.plugin_id not in failed_plugins]
await self._notify_ready(successful_plugins, sorted(failed_plugins))
activation_status = await self._activate_plugin(meta)
if activation_status == PluginActivationStatus.LOADED:
available_plugin_versions[meta.plugin_id] = meta.version
continue
if activation_status == PluginActivationStatus.INACTIVE:
inactive_plugins.add(meta.plugin_id)
continue
failed_plugins.add(meta.plugin_id)
successful_plugins = [
meta.plugin_id
for meta in plugins
if meta.plugin_id not in failed_plugins and meta.plugin_id not in inactive_plugins
]
await self._notify_ready(successful_plugins, sorted(failed_plugins), sorted(inactive_plugins))
# 5. 等待直到收到关停信号
with contextlib.suppress(asyncio.CancelledError):
@@ -352,17 +394,17 @@ class PluginRunner:
cast(_ContextAwarePlugin, instance)._set_context(ctx)
logger.debug(f"已为插件 {plugin_id} 注入 PluginContext")
def _apply_plugin_config(self, meta: PluginMeta, config_data: Optional[Dict[str, Any]] = None) -> None:
def _apply_plugin_config(self, meta: PluginMeta, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""在 Runner 侧为插件实例注入当前插件配置。
Args:
meta: 插件元数据。
config_data: 可选的配置数据;留空时自动从插件目录读取。
Returns:
Dict[str, Any]: 归一化后的当前插件配置。
"""
instance = meta.instance
if not hasattr(instance, "set_plugin_config"):
return
raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir)
plugin_config, should_persist = self._normalize_plugin_config(instance, raw_config)
config_path = Path(meta.plugin_dir) / "config.toml"
@@ -370,10 +412,12 @@ class PluginRunner:
should_initialize_file = not config_path.exists() and bool(default_config)
if should_persist or should_initialize_file:
self._save_plugin_config(meta.plugin_dir, plugin_config)
try:
cast(_ConfigAwarePlugin, instance).set_plugin_config(plugin_config)
except Exception as exc:
logger.warning(f"插件 {meta.plugin_id} 配置注入失败: {exc}")
if hasattr(instance, "set_plugin_config"):
try:
cast(_ConfigAwarePlugin, instance).set_plugin_config(plugin_config)
except Exception as exc:
logger.warning(f"插件 {meta.plugin_id} 配置注入失败: {exc}")
return plugin_config
def _normalize_plugin_config(
self,
@@ -405,6 +449,33 @@ class PluginRunner:
logger.warning(f"插件配置归一化失败,将回退为原始配置: {exc}")
return normalized_config, False
@staticmethod
def _is_plugin_enabled(config_data: Optional[Mapping[str, Any]]) -> bool:
"""根据配置内容判断插件是否应被视为启用。
Args:
config_data: 当前插件配置。
Returns:
bool: 插件是否启用。
"""
if not isinstance(config_data, Mapping):
return True
plugin_section = config_data.get("plugin")
if not isinstance(plugin_section, Mapping):
return True
enabled_value = plugin_section.get("enabled", True)
if isinstance(enabled_value, str):
normalized_value = enabled_value.strip().lower()
if normalized_value in {"0", "false", "no", "off"}:
return False
if normalized_value in {"1", "true", "yes", "on"}:
return True
return bool(enabled_value)
@staticmethod
def _save_plugin_config(plugin_dir: str, config_data: Dict[str, Any]) -> None:
"""将插件配置写回到 ``config.toml``。
@@ -435,6 +506,99 @@ class PluginRunner:
return loaded if isinstance(loaded, dict) else {}
def _resolve_plugin_candidate(self, plugin_id: str) -> Tuple[Optional[PluginCandidate], Optional[str]]:
"""解析指定插件的候选目录。
Args:
plugin_id: 目标插件 ID。
Returns:
Tuple[Optional[PluginCandidate], Optional[str]]: 候选插件与错误信息。
"""
candidates, duplicate_candidates = self._loader.discover_candidates(self._plugin_dirs)
if plugin_id in duplicate_candidates:
conflict_paths = ", ".join(str(path) for path in duplicate_candidates[plugin_id])
return None, f"检测到重复插件 ID: {conflict_paths}"
candidate = candidates.get(plugin_id)
if candidate is None:
return None, f"未找到插件: {plugin_id}"
return candidate, None
def _resolve_plugin_meta_for_config_request(
self,
plugin_id: str,
) -> Tuple[Optional[PluginMeta], bool, Optional[str]]:
"""为配置相关请求解析插件元数据。
Args:
plugin_id: 目标插件 ID。
Returns:
Tuple[Optional[PluginMeta], bool, Optional[str]]: 依次为插件元数据、
是否为临时冷加载实例、以及错误信息。
"""
loaded_meta = self._loader.get_plugin(plugin_id)
if loaded_meta is not None:
return loaded_meta, False, None
candidate, error_message = self._resolve_plugin_candidate(plugin_id)
if candidate is None:
return None, False, error_message
try:
meta = self._loader.load_candidate(plugin_id, candidate)
except Exception as exc:
return None, False, str(exc)
if meta is None:
return None, False, "插件模块加载失败"
return meta, True, None
def _inspect_plugin_config(
self,
meta: PluginMeta,
*,
config_data: Optional[Dict[str, Any]] = None,
use_provided_config: bool = False,
suppress_errors: bool = True,
) -> InspectPluginConfigResultPayload:
"""解析插件代码定义的配置元数据。
Args:
meta: 插件元数据。
config_data: 可选的配置内容。
use_provided_config: 是否优先使用传入的配置内容。
suppress_errors: 是否在归一化失败时回退原始配置。
Returns:
InspectPluginConfigResultPayload: 结构化解析结果。
"""
raw_config = config_data if use_provided_config else self._load_plugin_config(meta.plugin_dir)
if use_provided_config and config_data is None:
raw_config = {}
normalized_config, changed = self._normalize_plugin_config(
meta.instance,
raw_config,
suppress_errors=suppress_errors,
)
default_config = self._get_plugin_default_config(meta.instance)
if not normalized_config and not raw_config and default_config:
normalized_config = dict(default_config)
changed = True
return InspectPluginConfigResultPayload(
success=True,
default_config=default_config,
config_schema=self._get_plugin_config_schema(meta),
normalized_config=normalized_config,
changed=changed,
enabled=self._is_plugin_enabled(normalized_config),
)
def _register_handlers(self) -> None:
"""注册 Host -> Runner 的方法处理器。"""
self._rpc_client.register_method("plugin.invoke_command", self._handle_invoke)
@@ -448,6 +612,7 @@ class PluginRunner:
self._rpc_client.register_method("plugin.prepare_shutdown", self._handle_prepare_shutdown)
self._rpc_client.register_method("plugin.shutdown", self._handle_shutdown)
self._rpc_client.register_method("plugin.config_updated", self._handle_config_updated)
self._rpc_client.register_method("plugin.inspect_config", self._handle_inspect_plugin_config)
self._rpc_client.register_method("plugin.validate_config", self._handle_validate_plugin_config)
self._rpc_client.register_method("plugin.reload", self._handle_reload_plugin)
self._rpc_client.register_method("plugin.reload_batch", self._handle_reload_plugins)
@@ -579,6 +744,9 @@ class PluginRunner:
)
if response.error:
raise RuntimeError(response.error.get("message", "插件注册失败"))
response_payload = response.payload if isinstance(response.payload, dict) else {}
if not bool(response_payload.get("accepted", True)):
raise RuntimeError(str(response_payload.get("reason", "插件注册失败")))
logger.info(f"插件 {meta.plugin_id} 注册完成")
return True
except Exception as e:
@@ -689,36 +857,40 @@ class PluginRunner:
except Exception as exc:
logger.error(f"插件 {meta.plugin_id} on_unload 失败: {exc}", exc_info=True)
async def _activate_plugin(self, meta: PluginMeta) -> bool:
async def _activate_plugin(self, meta: PluginMeta) -> PluginActivationStatus:
"""完成插件注入、授权、生命周期和组件注册。
Args:
meta: 待激活的插件元数据。
Returns:
bool: 是否激活成功
PluginActivationStatus: 插件激活结果
"""
self._inject_context(meta.plugin_id, meta.instance)
self._apply_plugin_config(meta)
plugin_config = self._apply_plugin_config(meta)
if not self._is_plugin_enabled(plugin_config):
logger.info(f"插件 {meta.plugin_id} 已在配置中禁用,跳过激活")
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
return PluginActivationStatus.INACTIVE
if not await self._bootstrap_plugin(meta):
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
return False
return PluginActivationStatus.FAILED
if not await self._register_plugin(meta):
await self._invoke_plugin_on_unload(meta)
await self._deactivate_plugin(meta)
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
return False
return PluginActivationStatus.FAILED
if not await self._invoke_plugin_on_load(meta):
await self._unregister_plugin(meta.plugin_id, reason="on_load_failed")
await self._deactivate_plugin(meta)
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
return False
return PluginActivationStatus.FAILED
self._loader.set_loaded_plugin(meta)
return True
return PluginActivationStatus.LOADED
async def _unload_plugin(self, meta: PluginMeta, reason: str, *, purge_modules: bool = True) -> None:
"""卸载单个插件并清理 Host/Runner 两侧状态。
@@ -879,6 +1051,7 @@ class PluginRunner:
requested_plugin_id=plugin_id,
reloaded_plugins=batch_result.reloaded_plugins,
unloaded_plugins=batch_result.unloaded_plugins,
inactive_plugins=batch_result.inactive_plugins,
failed_plugins=batch_result.failed_plugins,
)
@@ -973,6 +1146,8 @@ class PluginRunner:
},
}
reloaded_plugins: List[str] = []
inactive_plugins: List[str] = []
inactive_plugin_ids: Set[str] = set()
for load_plugin_id in load_order:
if load_plugin_id in failed_plugins:
@@ -983,10 +1158,28 @@ class PluginRunner:
continue
_, manifest, _ = candidate
unsatisfied_dependency_ids = [
dependency.id
for dependency in manifest.plugin_dependencies
if dependency.id not in available_plugins
or not self._loader.manifest_validator.is_plugin_dependency_satisfied(
dependency,
available_plugins[dependency.id],
)
]
if unsatisfied_dependencies := self._loader.manifest_validator.get_unsatisfied_plugin_dependencies(
manifest,
available_plugin_versions=available_plugins,
):
if load_plugin_id not in reload_root_ids and any(
dependency_id in inactive_plugin_ids for dependency_id in unsatisfied_dependency_ids
):
logger.info(
f"插件 {load_plugin_id} 的依赖当前未激活,保留为未激活状态: {', '.join(unsatisfied_dependencies)}"
)
inactive_plugin_ids.add(load_plugin_id)
inactive_plugins.append(load_plugin_id)
continue
failed_plugins[load_plugin_id] = f"依赖未满足: {', '.join(unsatisfied_dependencies)}"
continue
@@ -996,9 +1189,13 @@ class PluginRunner:
continue
activated = await self._activate_plugin(meta)
if not activated:
if activated == PluginActivationStatus.FAILED:
failed_plugins[load_plugin_id] = "插件初始化失败"
continue
if activated == PluginActivationStatus.INACTIVE:
inactive_plugin_ids.add(load_plugin_id)
inactive_plugins.append(load_plugin_id)
continue
available_plugins[load_plugin_id] = meta.version
reloaded_plugins.append(load_plugin_id)
@@ -1033,7 +1230,7 @@ class PluginRunner:
rollback_failures[rollback_plugin_id] = str(exc)
continue
if not restored:
if restored != PluginActivationStatus.LOADED:
rollback_failures[rollback_plugin_id] = "无法重新激活旧版本"
return ReloadPluginsResultPayload(
@@ -1041,29 +1238,40 @@ class PluginRunner:
requested_plugin_ids=normalized_plugin_ids,
reloaded_plugins=[],
unloaded_plugins=unloaded_plugins,
inactive_plugins=[],
failed_plugins=self._finalize_failed_reload_messages(failed_plugins, rollback_failures),
)
requested_plugin_success = all(plugin_id in reloaded_plugins for plugin_id in reload_root_ids)
requested_plugin_success = all(
plugin_id in reloaded_plugins or plugin_id in inactive_plugins for plugin_id in reload_root_ids
)
return ReloadPluginsResultPayload(
success=requested_plugin_success and not failed_plugins,
requested_plugin_ids=normalized_plugin_ids,
reloaded_plugins=reloaded_plugins,
unloaded_plugins=unloaded_plugins,
inactive_plugins=inactive_plugins,
failed_plugins=failed_plugins,
)
async def _notify_ready(self, loaded_plugins: List[str], failed_plugins: List[str]) -> None:
async def _notify_ready(
self,
loaded_plugins: List[str],
failed_plugins: List[str],
inactive_plugins: List[str],
) -> None:
"""通知 Host 当前 Runner 已完成插件初始化。
Args:
loaded_plugins: 成功初始化的插件列表。
failed_plugins: 初始化失败的插件列表。
inactive_plugins: 因禁用或依赖不可用而未激活的插件列表。
"""
payload = RunnerReadyPayload(
loaded_plugins=loaded_plugins,
failed_plugins=failed_plugins,
inactive_plugins=inactive_plugins,
)
await self._rpc_client.send_request(
"runner.ready",
@@ -1289,6 +1497,44 @@ class PluginRunner:
return envelope.make_error_response(ErrorCode.E_UNKNOWN.value, str(e))
return envelope.make_response(payload={"acknowledged": True})
async def _handle_inspect_plugin_config(self, envelope: Envelope) -> Envelope:
"""处理插件配置元数据解析请求。
Args:
envelope: RPC 请求信封。
Returns:
Envelope: RPC 响应信封。
"""
try:
payload = InspectPluginConfigPayload.model_validate(envelope.payload)
except Exception as exc:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
plugin_id = envelope.plugin_id
meta, is_temporary_meta, error_message = self._resolve_plugin_meta_for_config_request(plugin_id)
if meta is None:
return envelope.make_error_response(
ErrorCode.E_PLUGIN_NOT_FOUND.value,
error_message or f"未找到插件: {plugin_id}",
)
try:
result = self._inspect_plugin_config(
meta,
config_data=payload.config_data,
use_provided_config=payload.use_provided_config,
suppress_errors=True,
)
except Exception as exc:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
finally:
if is_temporary_meta:
self._loader.purge_plugin_modules(plugin_id, meta.plugin_dir)
return envelope.make_response(payload=result.model_dump())
async def _handle_validate_plugin_config(self, envelope: Envelope) -> Envelope:
"""处理插件配置校验请求。
@@ -1305,23 +1551,30 @@ class PluginRunner:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
plugin_id = envelope.plugin_id
meta = self._loader.get_plugin(plugin_id)
meta, is_temporary_meta, error_message = self._resolve_plugin_meta_for_config_request(plugin_id)
if meta is None:
return envelope.make_error_response(ErrorCode.E_PLUGIN_NOT_FOUND.value, f"未找到插件: {plugin_id}")
return envelope.make_error_response(
ErrorCode.E_PLUGIN_NOT_FOUND.value,
error_message or f"未找到插件: {plugin_id}",
)
try:
normalized_config, changed = self._normalize_plugin_config(
meta.instance,
payload.config_data,
inspection_result = self._inspect_plugin_config(
meta,
config_data=payload.config_data,
use_provided_config=True,
suppress_errors=False,
)
except Exception as exc:
return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc))
finally:
if is_temporary_meta:
self._loader.purge_plugin_modules(plugin_id, meta.plugin_dir)
result = ValidatePluginConfigResultPayload(
success=True,
normalized_config=normalized_config,
changed=changed,
normalized_config=inspection_result.normalized_config,
changed=inspection_result.changed,
)
return envelope.make_response(payload=result.model_dump())