merge: 同步 upstream/r-dev 并解决冲突
This commit is contained in:
@@ -20,5 +20,8 @@ ENV_HOST_VERSION = "MAIBOT_HOST_VERSION"
|
||||
ENV_EXTERNAL_PLUGIN_IDS = "MAIBOT_EXTERNAL_PLUGIN_IDS"
|
||||
"""Runner 启动时可视为已满足的外部插件依赖版本映射(JSON 对象)"""
|
||||
|
||||
ENV_BLOCKED_PLUGIN_REASONS = "MAIBOT_BLOCKED_PLUGIN_REASONS"
|
||||
"""Runner 启动时收到的拒绝加载插件原因映射(JSON 对象)"""
|
||||
|
||||
ENV_GLOBAL_CONFIG_SNAPSHOT = "MAIBOT_GLOBAL_CONFIG_SNAPSHOT"
|
||||
"""Runner 启动时注入的全局配置快照(JSON 对象)"""
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from .components import RuntimeComponentCapabilityMixin
|
||||
from .core import RuntimeCoreCapabilityMixin
|
||||
from .data import RuntimeDataCapabilityMixin
|
||||
from .render import RuntimeRenderCapabilityMixin
|
||||
|
||||
__all__ = [
|
||||
"RuntimeComponentCapabilityMixin",
|
||||
"RuntimeCoreCapabilityMixin",
|
||||
"RuntimeDataCapabilityMixin",
|
||||
"RuntimeRenderCapabilityMixin",
|
||||
]
|
||||
|
||||
@@ -458,6 +458,17 @@ class RuntimeComponentCapabilityMixin:
|
||||
async def _cap_component_get_plugin_info(
|
||||
self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""获取指定插件的基础信息。
|
||||
|
||||
Args:
|
||||
plugin_id: 当前调用方插件 ID。
|
||||
capability: 当前能力名称。
|
||||
args: 能力调用参数。
|
||||
|
||||
Returns:
|
||||
Any: 插件基础信息响应。
|
||||
"""
|
||||
|
||||
plugin_name: str = args.get("plugin_name", plugin_id)
|
||||
try:
|
||||
sv = self._get_supervisor_for_plugin(plugin_name)
|
||||
@@ -473,10 +484,46 @@ class RuntimeComponentCapabilityMixin:
|
||||
"description": "",
|
||||
"author": "",
|
||||
"enabled": True,
|
||||
"default_config": reg.default_config,
|
||||
"config_schema": reg.config_schema,
|
||||
},
|
||||
}
|
||||
return {"success": False, "error": f"未找到插件: {plugin_name}"}
|
||||
|
||||
async def _cap_component_get_plugin_config_schema(
|
||||
self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""获取指定插件注册时上报的配置 Schema。
|
||||
|
||||
Args:
|
||||
plugin_id: 当前调用方插件 ID。
|
||||
capability: 当前能力名称。
|
||||
args: 能力调用参数。
|
||||
|
||||
Returns:
|
||||
Any: 包含配置 Schema 与默认配置的响应。
|
||||
"""
|
||||
|
||||
plugin_name: str = args.get("plugin_name", plugin_id)
|
||||
try:
|
||||
sv = self._get_supervisor_for_plugin(plugin_name)
|
||||
except RuntimeError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
if sv is None:
|
||||
return {"success": False, "error": f"未找到插件: {plugin_name}"}
|
||||
|
||||
registration = sv._registered_plugins.get(plugin_name)
|
||||
if registration is None:
|
||||
return {"success": False, "error": f"未找到插件: {plugin_name}"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"plugin_id": plugin_name,
|
||||
"schema": registration.config_schema,
|
||||
"default_config": registration.default_config,
|
||||
}
|
||||
|
||||
async def _cap_component_list_loaded_plugins(
|
||||
self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
@@ -81,6 +81,7 @@ def register_capability_impls(manager: "PluginRuntimeManager", supervisor: Plugi
|
||||
|
||||
_register("component.get_all_plugins", manager._cap_component_get_all_plugins)
|
||||
_register("component.get_plugin_info", manager._cap_component_get_plugin_info)
|
||||
_register("component.get_plugin_config_schema", manager._cap_component_get_plugin_config_schema)
|
||||
_register("component.list_loaded_plugins", manager._cap_component_list_loaded_plugins)
|
||||
_register("component.list_registered_plugins", manager._cap_component_list_registered_plugins)
|
||||
_register("component.enable", manager._cap_component_enable)
|
||||
@@ -90,4 +91,5 @@ def register_capability_impls(manager: "PluginRuntimeManager", supervisor: Plugi
|
||||
_register("component.reload_plugin", manager._cap_component_reload_plugin)
|
||||
|
||||
_register("knowledge.search", manager._cap_knowledge_search)
|
||||
_register("render.html2png", manager._cap_render_html2png)
|
||||
logger.debug("已注册全部主程序能力实现")
|
||||
|
||||
121
src/plugin_runtime/capabilities/render.py
Normal file
121
src/plugin_runtime/capabilities/render.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""插件运行时的浏览器渲染能力。"""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.services.html_render_service import HtmlRenderRequest, get_html_render_service
|
||||
|
||||
logger = get_logger("plugin_runtime.integration")
|
||||
|
||||
|
||||
class RuntimeRenderCapabilityMixin:
|
||||
"""插件运行时的浏览器渲染能力混入。"""
|
||||
|
||||
@staticmethod
|
||||
def _coerce_int(value: Any, default: int) -> int:
|
||||
"""将任意值尽量转换为整数。
|
||||
|
||||
Args:
|
||||
value: 原始输入值。
|
||||
default: 转换失败时返回的默认值。
|
||||
|
||||
Returns:
|
||||
int: 规范化后的整数结果。
|
||||
"""
|
||||
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _coerce_float(value: Any, default: float) -> float:
|
||||
"""将任意值尽量转换为浮点数。
|
||||
|
||||
Args:
|
||||
value: 原始输入值。
|
||||
default: 转换失败时返回的默认值。
|
||||
|
||||
Returns:
|
||||
float: 规范化后的浮点结果。
|
||||
"""
|
||||
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _coerce_bool(value: Any, default: bool = False) -> bool:
|
||||
"""将任意值转换为布尔值。
|
||||
|
||||
Args:
|
||||
value: 原始输入值。
|
||||
default: 输入为空时返回的默认值。
|
||||
|
||||
Returns:
|
||||
bool: 规范化后的布尔结果。
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
normalized_value = 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(value)
|
||||
|
||||
def _build_html_render_request(self, args: Dict[str, Any]) -> HtmlRenderRequest:
|
||||
"""根据 capability 调用参数构造渲染请求。
|
||||
|
||||
Args:
|
||||
args: capability 调用参数。
|
||||
|
||||
Returns:
|
||||
HtmlRenderRequest: 结构化后的渲染请求。
|
||||
"""
|
||||
|
||||
viewport = args.get("viewport", {})
|
||||
viewport_width = 900
|
||||
viewport_height = 500
|
||||
if isinstance(viewport, dict):
|
||||
viewport_width = self._coerce_int(viewport.get("width"), viewport_width)
|
||||
viewport_height = self._coerce_int(viewport.get("height"), viewport_height)
|
||||
|
||||
return HtmlRenderRequest(
|
||||
html=str(args.get("html", "") or ""),
|
||||
selector=str(args.get("selector", "body") or "body"),
|
||||
viewport_width=viewport_width,
|
||||
viewport_height=viewport_height,
|
||||
device_scale_factor=self._coerce_float(args.get("device_scale_factor"), 2.0),
|
||||
full_page=self._coerce_bool(args.get("full_page"), False),
|
||||
omit_background=self._coerce_bool(args.get("omit_background"), False),
|
||||
wait_until=str(args.get("wait_until", "load") or "load"),
|
||||
wait_for_selector=str(args.get("wait_for_selector", "") or ""),
|
||||
wait_for_timeout_ms=self._coerce_int(args.get("wait_for_timeout_ms"), 0),
|
||||
timeout_ms=self._coerce_int(args.get("timeout_ms"), 0),
|
||||
allow_network=self._coerce_bool(args.get("allow_network"), False),
|
||||
)
|
||||
|
||||
async def _cap_render_html2png(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any:
|
||||
"""将 HTML 内容渲染为 PNG 图片。
|
||||
|
||||
Args:
|
||||
plugin_id: 调用该能力的插件 ID。
|
||||
capability: 当前能力名称。
|
||||
args: 能力调用参数。
|
||||
|
||||
Returns:
|
||||
Any: 标准化后的能力返回结构。
|
||||
"""
|
||||
|
||||
del plugin_id, capability
|
||||
try:
|
||||
request = self._build_html_render_request(args)
|
||||
result = await get_html_render_service().render_html_to_png(request)
|
||||
return {"success": True, "result": result.to_payload()}
|
||||
except Exception as exc:
|
||||
logger.error(f"[cap.render.html2png] 执行失败: {exc}", exc_info=True)
|
||||
return {"success": False, "error": str(exc)}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional, Tuple, cast
|
||||
|
||||
from src.common.logger import get_logger
|
||||
@@ -858,5 +859,77 @@ class ComponentQueryService:
|
||||
logger.error(f"读取插件 {plugin_name} 配置失败: {exc}", exc_info=True)
|
||||
return None
|
||||
|
||||
def get_plugin_default_config(self, plugin_name: str) -> Optional[dict]:
|
||||
"""获取指定插件注册时上报的默认配置。
|
||||
|
||||
Args:
|
||||
plugin_name: 插件名称。
|
||||
|
||||
Returns:
|
||||
Optional[dict]: 默认配置字典;未找到时返回 ``None``。
|
||||
"""
|
||||
|
||||
runtime_manager = self._get_runtime_manager()
|
||||
try:
|
||||
supervisor = runtime_manager._get_supervisor_for_plugin(plugin_name)
|
||||
except RuntimeError as exc:
|
||||
logger.error(f"读取插件默认配置失败: {exc}")
|
||||
return None
|
||||
|
||||
if supervisor is None:
|
||||
return None
|
||||
|
||||
registration = supervisor._registered_plugins.get(plugin_name)
|
||||
if registration is None:
|
||||
return None
|
||||
return dict(registration.default_config)
|
||||
|
||||
def get_plugin_config_schema(self, plugin_name: str) -> Optional[dict]:
|
||||
"""获取指定插件注册时上报的配置 Schema。
|
||||
|
||||
Args:
|
||||
plugin_name: 插件名称。
|
||||
|
||||
Returns:
|
||||
Optional[dict]: 配置 Schema;未找到时返回 ``None``。
|
||||
"""
|
||||
|
||||
runtime_manager = self._get_runtime_manager()
|
||||
try:
|
||||
supervisor = runtime_manager._get_supervisor_for_plugin(plugin_name)
|
||||
except RuntimeError as exc:
|
||||
logger.error(f"读取插件配置 Schema 失败: {exc}")
|
||||
return None
|
||||
|
||||
if supervisor is None:
|
||||
return None
|
||||
|
||||
registration = supervisor._registered_plugins.get(plugin_name)
|
||||
if registration is None:
|
||||
return None
|
||||
return dict(registration.config_schema)
|
||||
|
||||
def list_hook_specs(self) -> list[dict[str, Any]]:
|
||||
"""返回当前运行时公开的 Hook 规格清单。
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: 可直接序列化给 WebUI 的 Hook 规格列表。
|
||||
"""
|
||||
|
||||
runtime_manager = self._get_runtime_manager()
|
||||
return [
|
||||
{
|
||||
"name": spec.name,
|
||||
"description": spec.description,
|
||||
"parameters_schema": deepcopy(spec.parameters_schema),
|
||||
"default_timeout_ms": spec.default_timeout_ms,
|
||||
"allow_blocking": spec.allow_blocking,
|
||||
"allow_observe": spec.allow_observe,
|
||||
"allow_abort": spec.allow_abort,
|
||||
"allow_kwargs_mutation": spec.allow_kwargs_mutation,
|
||||
}
|
||||
for spec in runtime_manager.list_hook_specs()
|
||||
]
|
||||
|
||||
|
||||
component_query_service = ComponentQueryService()
|
||||
|
||||
441
src/plugin_runtime/dependency_pipeline.py
Normal file
441
src/plugin_runtime/dependency_pipeline.py
Normal file
@@ -0,0 +1,441 @@
|
||||
"""插件 Python 依赖流水线。
|
||||
|
||||
负责在 Host 侧统一完成以下工作:
|
||||
1. 扫描插件 Manifest;
|
||||
2. 检测插件与主程序、插件与插件之间的 Python 依赖冲突;
|
||||
3. 为可加载插件自动安装缺失的 Python 依赖;
|
||||
4. 产出最终的拒绝加载列表,供运行时使用。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator, PluginManifest
|
||||
|
||||
|
||||
logger = get_logger("plugin_runtime.dependency_pipeline")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackageDependencyUsage:
|
||||
"""记录单个插件对某个 Python 包的依赖声明。"""
|
||||
|
||||
package_name: str
|
||||
plugin_id: str
|
||||
version_spec: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CombinedPackageRequirement:
|
||||
"""表示一个已经合并后的 Python 包安装需求。"""
|
||||
|
||||
package_name: str
|
||||
plugin_ids: Tuple[str, ...]
|
||||
requirement_text: str
|
||||
version_spec: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DependencyPipelinePlan:
|
||||
"""表示一次依赖分析后得到的计划。"""
|
||||
|
||||
blocked_plugin_reasons: Dict[str, str]
|
||||
install_requirements: Tuple[CombinedPackageRequirement, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DependencyPipelineResult:
|
||||
"""表示一次依赖流水线执行后的结果。"""
|
||||
|
||||
blocked_plugin_reasons: Dict[str, str]
|
||||
environment_changed: bool
|
||||
install_requirements: Tuple[CombinedPackageRequirement, ...]
|
||||
|
||||
|
||||
class PluginDependencyPipeline:
|
||||
"""插件依赖流水线。
|
||||
|
||||
该类不负责插件启停,只负责对插件目录进行依赖分析,并在必要时
|
||||
使用 ``uv`` 为可加载插件补齐缺失的 Python 依赖。
|
||||
"""
|
||||
|
||||
def __init__(self, project_root: Optional[Path] = None) -> None:
|
||||
"""初始化依赖流水线。
|
||||
|
||||
Args:
|
||||
project_root: 项目根目录;留空时自动推断。
|
||||
"""
|
||||
|
||||
self._project_root: Path = project_root or Path(__file__).resolve().parents[2]
|
||||
self._manifest_validator: ManifestValidator = ManifestValidator(
|
||||
project_root=self._project_root,
|
||||
validate_python_package_dependencies=False,
|
||||
)
|
||||
|
||||
async def execute(self, plugin_dirs: Iterable[Path]) -> DependencyPipelineResult:
|
||||
"""执行完整的依赖分析与自动安装流程。
|
||||
|
||||
Args:
|
||||
plugin_dirs: 需要扫描的插件根目录集合。
|
||||
|
||||
Returns:
|
||||
DependencyPipelineResult: 最终的阻止加载结果与环境变更状态。
|
||||
"""
|
||||
|
||||
plan = self.build_plan(plugin_dirs)
|
||||
if not plan.install_requirements:
|
||||
return DependencyPipelineResult(
|
||||
blocked_plugin_reasons=dict(plan.blocked_plugin_reasons),
|
||||
environment_changed=False,
|
||||
install_requirements=plan.install_requirements,
|
||||
)
|
||||
|
||||
install_succeeded, error_message = await self._install_requirements(plan.install_requirements)
|
||||
if install_succeeded:
|
||||
return DependencyPipelineResult(
|
||||
blocked_plugin_reasons=dict(plan.blocked_plugin_reasons),
|
||||
environment_changed=True,
|
||||
install_requirements=plan.install_requirements,
|
||||
)
|
||||
|
||||
blocked_plugin_reasons = dict(plan.blocked_plugin_reasons)
|
||||
affected_plugin_ids = sorted(
|
||||
{
|
||||
plugin_id
|
||||
for requirement in plan.install_requirements
|
||||
for plugin_id in requirement.plugin_ids
|
||||
}
|
||||
)
|
||||
for plugin_id in affected_plugin_ids:
|
||||
self._append_block_reason(
|
||||
blocked_plugin_reasons,
|
||||
plugin_id,
|
||||
f"自动安装 Python 依赖失败: {error_message}",
|
||||
)
|
||||
|
||||
return DependencyPipelineResult(
|
||||
blocked_plugin_reasons=blocked_plugin_reasons,
|
||||
environment_changed=False,
|
||||
install_requirements=plan.install_requirements,
|
||||
)
|
||||
|
||||
def build_plan(self, plugin_dirs: Iterable[Path]) -> DependencyPipelinePlan:
|
||||
"""构建依赖分析计划。
|
||||
|
||||
Args:
|
||||
plugin_dirs: 需要扫描的插件根目录集合。
|
||||
|
||||
Returns:
|
||||
DependencyPipelinePlan: 分析后的阻止加载列表与安装计划。
|
||||
"""
|
||||
|
||||
manifests = self._collect_manifests(plugin_dirs)
|
||||
blocked_plugin_reasons = self._detect_host_conflicts(manifests)
|
||||
plugin_conflict_reasons = self._detect_plugin_conflicts(manifests, blocked_plugin_reasons)
|
||||
for plugin_id, reason in plugin_conflict_reasons.items():
|
||||
self._append_block_reason(blocked_plugin_reasons, plugin_id, reason)
|
||||
|
||||
install_requirements = self._build_install_requirements(manifests, blocked_plugin_reasons)
|
||||
return DependencyPipelinePlan(
|
||||
blocked_plugin_reasons=blocked_plugin_reasons,
|
||||
install_requirements=install_requirements,
|
||||
)
|
||||
|
||||
def _collect_manifests(self, plugin_dirs: Iterable[Path]) -> Dict[str, PluginManifest]:
|
||||
"""收集所有可成功解析的插件 Manifest。
|
||||
|
||||
Args:
|
||||
plugin_dirs: 需要扫描的插件根目录集合。
|
||||
|
||||
Returns:
|
||||
Dict[str, PluginManifest]: 以插件 ID 为键的 Manifest 映射。
|
||||
"""
|
||||
|
||||
manifests: Dict[str, PluginManifest] = {}
|
||||
for _plugin_path, manifest in self._manifest_validator.iter_plugin_manifests(plugin_dirs):
|
||||
manifests[manifest.id] = manifest
|
||||
return manifests
|
||||
|
||||
def _detect_host_conflicts(self, manifests: Dict[str, PluginManifest]) -> Dict[str, str]:
|
||||
"""检测插件与主程序依赖之间的冲突。
|
||||
|
||||
Args:
|
||||
manifests: 当前已解析到的插件 Manifest 映射。
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: 需要被阻止加载的插件及原因。
|
||||
"""
|
||||
|
||||
host_requirements = self._manifest_validator.load_host_dependency_requirements()
|
||||
blocked_plugin_reasons: Dict[str, str] = {}
|
||||
|
||||
for manifest in manifests.values():
|
||||
for dependency in manifest.python_package_dependencies:
|
||||
package_specifier = self._manifest_validator.build_specifier_set(dependency.version_spec)
|
||||
if package_specifier is None:
|
||||
self._append_block_reason(
|
||||
blocked_plugin_reasons,
|
||||
manifest.id,
|
||||
f"Python 包依赖声明无效: {dependency.name}{dependency.version_spec}",
|
||||
)
|
||||
continue
|
||||
|
||||
normalized_package_name = canonicalize_name(dependency.name)
|
||||
host_requirement = host_requirements.get(normalized_package_name)
|
||||
if host_requirement is None:
|
||||
continue
|
||||
|
||||
if self._manifest_validator.requirements_may_overlap(
|
||||
host_requirement.specifier,
|
||||
package_specifier,
|
||||
):
|
||||
continue
|
||||
|
||||
host_specifier_text = str(host_requirement.specifier or "") or "任意版本"
|
||||
self._append_block_reason(
|
||||
blocked_plugin_reasons,
|
||||
manifest.id,
|
||||
(
|
||||
f"Python 包依赖与主程序冲突: {dependency.name} 需要 "
|
||||
f"{dependency.version_spec},主程序约束为 {host_specifier_text}"
|
||||
),
|
||||
)
|
||||
|
||||
return blocked_plugin_reasons
|
||||
|
||||
def _detect_plugin_conflicts(
|
||||
self,
|
||||
manifests: Dict[str, PluginManifest],
|
||||
blocked_plugin_reasons: Dict[str, str],
|
||||
) -> Dict[str, str]:
|
||||
"""检测插件之间的 Python 依赖冲突。
|
||||
|
||||
Args:
|
||||
manifests: 当前已解析到的插件 Manifest 映射。
|
||||
blocked_plugin_reasons: 已经因为其他原因被阻止加载的插件。
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: 新增的插件冲突原因映射。
|
||||
"""
|
||||
|
||||
blocked_by_plugin_conflicts: Dict[str, str] = {}
|
||||
dependency_usages = self._collect_package_usages(manifests, blocked_plugin_reasons)
|
||||
|
||||
for _package_name, usages in dependency_usages.items():
|
||||
display_package_name = usages[0].package_name
|
||||
for index, left_usage in enumerate(usages):
|
||||
for right_usage in usages[index + 1 :]:
|
||||
left_specifier = self._manifest_validator.build_specifier_set(left_usage.version_spec)
|
||||
right_specifier = self._manifest_validator.build_specifier_set(right_usage.version_spec)
|
||||
if left_specifier is None or right_specifier is None:
|
||||
continue
|
||||
|
||||
if self._manifest_validator.requirements_may_overlap(left_specifier, right_specifier):
|
||||
continue
|
||||
|
||||
left_reason = (
|
||||
f"Python 包依赖冲突: 与插件 {right_usage.plugin_id} 在 {display_package_name} 上的约束不兼容 "
|
||||
f"({left_usage.version_spec} vs {right_usage.version_spec})"
|
||||
)
|
||||
right_reason = (
|
||||
f"Python 包依赖冲突: 与插件 {left_usage.plugin_id} 在 {display_package_name} 上的约束不兼容 "
|
||||
f"({right_usage.version_spec} vs {left_usage.version_spec})"
|
||||
)
|
||||
self._append_block_reason(blocked_by_plugin_conflicts, left_usage.plugin_id, left_reason)
|
||||
self._append_block_reason(blocked_by_plugin_conflicts, right_usage.plugin_id, right_reason)
|
||||
|
||||
return blocked_by_plugin_conflicts
|
||||
|
||||
def _collect_package_usages(
|
||||
self,
|
||||
manifests: Dict[str, PluginManifest],
|
||||
blocked_plugin_reasons: Dict[str, str],
|
||||
) -> Dict[str, List[PackageDependencyUsage]]:
|
||||
"""收集所有未被阻止加载插件的包依赖声明。
|
||||
|
||||
Args:
|
||||
manifests: 当前已解析到的插件 Manifest 映射。
|
||||
blocked_plugin_reasons: 已经被阻止加载的插件及原因。
|
||||
|
||||
Returns:
|
||||
Dict[str, List[PackageDependencyUsage]]: 按规范化包名分组后的依赖声明。
|
||||
"""
|
||||
|
||||
dependency_usages: Dict[str, List[PackageDependencyUsage]] = {}
|
||||
for manifest in manifests.values():
|
||||
if manifest.id in blocked_plugin_reasons:
|
||||
continue
|
||||
|
||||
for dependency in manifest.python_package_dependencies:
|
||||
normalized_package_name = canonicalize_name(dependency.name)
|
||||
dependency_usages.setdefault(normalized_package_name, []).append(
|
||||
PackageDependencyUsage(
|
||||
package_name=dependency.name,
|
||||
plugin_id=manifest.id,
|
||||
version_spec=dependency.version_spec,
|
||||
)
|
||||
)
|
||||
|
||||
return dependency_usages
|
||||
|
||||
def _build_install_requirements(
|
||||
self,
|
||||
manifests: Dict[str, PluginManifest],
|
||||
blocked_plugin_reasons: Dict[str, str],
|
||||
) -> Tuple[CombinedPackageRequirement, ...]:
|
||||
"""构建需要安装到当前环境的 Python 包需求列表。
|
||||
|
||||
Args:
|
||||
manifests: 当前已解析到的插件 Manifest 映射。
|
||||
blocked_plugin_reasons: 已经被阻止加载的插件及原因。
|
||||
|
||||
Returns:
|
||||
Tuple[CombinedPackageRequirement, ...]: 需要安装或调整版本的依赖列表。
|
||||
"""
|
||||
|
||||
combined_requirements: List[CombinedPackageRequirement] = []
|
||||
dependency_usages = self._collect_package_usages(manifests, blocked_plugin_reasons)
|
||||
|
||||
for usages in dependency_usages.values():
|
||||
merged_specifier_text = self._merge_specifier_texts([usage.version_spec for usage in usages])
|
||||
package_name = usages[0].package_name
|
||||
requirement_text = f"{package_name}{merged_specifier_text}"
|
||||
installed_version = self._manifest_validator.get_installed_package_version(package_name)
|
||||
if installed_version is not None and self._manifest_validator.version_matches_specifier(
|
||||
installed_version,
|
||||
merged_specifier_text,
|
||||
):
|
||||
continue
|
||||
|
||||
combined_requirements.append(
|
||||
CombinedPackageRequirement(
|
||||
package_name=package_name,
|
||||
plugin_ids=tuple(sorted({usage.plugin_id for usage in usages})),
|
||||
requirement_text=requirement_text,
|
||||
version_spec=merged_specifier_text,
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(sorted(combined_requirements, key=lambda requirement: canonicalize_name(requirement.package_name)))
|
||||
|
||||
@staticmethod
|
||||
def _merge_specifier_texts(specifier_texts: Sequence[str]) -> str:
|
||||
"""合并多个版本约束文本。
|
||||
|
||||
Args:
|
||||
specifier_texts: 需要合并的版本约束文本序列。
|
||||
|
||||
Returns:
|
||||
str: 合并后的版本约束文本。
|
||||
"""
|
||||
|
||||
merged_parts: List[str] = []
|
||||
for specifier_text in specifier_texts:
|
||||
for part in str(specifier_text or "").split(","):
|
||||
normalized_part = part.strip()
|
||||
if not normalized_part or normalized_part in merged_parts:
|
||||
continue
|
||||
merged_parts.append(normalized_part)
|
||||
return f"{','.join(merged_parts)}" if merged_parts else ""
|
||||
|
||||
async def _install_requirements(self, requirements: Sequence[CombinedPackageRequirement]) -> Tuple[bool, str]:
|
||||
"""安装指定的 Python 包需求列表。
|
||||
|
||||
Args:
|
||||
requirements: 需要安装的依赖列表。
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: 安装是否成功,以及错误摘要。
|
||||
"""
|
||||
|
||||
requirement_texts = [requirement.requirement_text for requirement in requirements]
|
||||
if not requirement_texts:
|
||||
return True, ""
|
||||
|
||||
logger.info(f"开始自动安装插件 Python 依赖: {', '.join(requirement_texts)}")
|
||||
command = self._build_install_command(requirement_texts)
|
||||
|
||||
try:
|
||||
completed_process = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
command,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
cwd=self._project_root,
|
||||
text=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
if completed_process.returncode == 0:
|
||||
logger.info("插件 Python 依赖自动安装完成")
|
||||
return True, ""
|
||||
|
||||
output = self._summarize_install_error(completed_process.stdout, completed_process.stderr)
|
||||
return False, output or f"命令执行失败,退出码 {completed_process.returncode}"
|
||||
|
||||
@staticmethod
|
||||
def _build_install_command(requirement_texts: Sequence[str]) -> List[str]:
|
||||
"""构造依赖安装命令。
|
||||
|
||||
Args:
|
||||
requirement_texts: 待安装的依赖文本序列。
|
||||
|
||||
Returns:
|
||||
List[str]: 适用于 ``subprocess.run`` 的命令参数列表。
|
||||
"""
|
||||
|
||||
if shutil.which("uv"):
|
||||
return ["uv", "pip", "install", "--python", sys.executable, *requirement_texts]
|
||||
return [sys.executable, "-m", "pip", "install", *requirement_texts]
|
||||
|
||||
@staticmethod
|
||||
def _summarize_install_error(stdout: str, stderr: str) -> str:
|
||||
"""提炼安装失败输出。
|
||||
|
||||
Args:
|
||||
stdout: 标准输出内容。
|
||||
stderr: 标准错误内容。
|
||||
|
||||
Returns:
|
||||
str: 简短的错误摘要。
|
||||
"""
|
||||
|
||||
merged_output = "\n".join(part.strip() for part in (stderr, stdout) if part and part.strip()).strip()
|
||||
if not merged_output:
|
||||
return ""
|
||||
lines = [line.strip() for line in merged_output.splitlines() if line.strip()]
|
||||
return " | ".join(lines[-5:])
|
||||
|
||||
@staticmethod
|
||||
def _append_block_reason(
|
||||
blocked_plugin_reasons: Dict[str, str],
|
||||
plugin_id: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""向阻止加载映射中追加原因。
|
||||
|
||||
Args:
|
||||
blocked_plugin_reasons: 待更新的阻止加载映射。
|
||||
plugin_id: 目标插件 ID。
|
||||
reason: 需要追加的原因文本。
|
||||
"""
|
||||
|
||||
existing_reason = blocked_plugin_reasons.get(plugin_id)
|
||||
if existing_reason is None:
|
||||
blocked_plugin_reasons[plugin_id] = reason
|
||||
return
|
||||
|
||||
existing_parts = [part.strip() for part in existing_reason.split(";") if part.strip()]
|
||||
if reason in existing_parts:
|
||||
return
|
||||
blocked_plugin_reasons[plugin_id] = f"{existing_reason};{reason}"
|
||||
52
src/plugin_runtime/hook_catalog.py
Normal file
52
src/plugin_runtime/hook_catalog.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""内置命名 Hook 目录注册器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import List
|
||||
|
||||
from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry
|
||||
|
||||
|
||||
HookSpecRegistrar = Callable[[HookSpecRegistry], List[HookSpec]]
|
||||
"""单个业务模块向注册中心写入 Hook 规格的注册器签名。"""
|
||||
|
||||
|
||||
def _get_builtin_hook_spec_registrars() -> List[HookSpecRegistrar]:
|
||||
"""返回当前内置 Hook 规格注册器列表。
|
||||
|
||||
Returns:
|
||||
List[HookSpecRegistrar]: 已启用的内置 Hook 注册器列表。
|
||||
"""
|
||||
|
||||
from src.chat.message_receive.bot import register_chat_hook_specs
|
||||
from src.chat.emoji_system.emoji_manager import register_emoji_hook_specs
|
||||
from src.learners.expression_learner import register_expression_hook_specs
|
||||
from src.learners.jargon_miner import register_jargon_hook_specs
|
||||
from src.maisaka.chat_loop_service import register_maisaka_hook_specs
|
||||
from src.services.send_service import register_send_service_hook_specs
|
||||
|
||||
return [
|
||||
register_chat_hook_specs,
|
||||
register_emoji_hook_specs,
|
||||
register_jargon_hook_specs,
|
||||
register_expression_hook_specs,
|
||||
register_send_service_hook_specs,
|
||||
register_maisaka_hook_specs,
|
||||
]
|
||||
|
||||
|
||||
def register_builtin_hook_specs(registry: HookSpecRegistry) -> List[HookSpec]:
|
||||
"""向注册中心写入全部内置 Hook 规格。
|
||||
|
||||
Args:
|
||||
registry: 目标 Hook 规格注册中心。
|
||||
|
||||
Returns:
|
||||
List[HookSpec]: 本次完成注册后的全部内置 Hook 规格。
|
||||
"""
|
||||
|
||||
registered_specs: List[HookSpec] = []
|
||||
for registrar in _get_builtin_hook_spec_registrars():
|
||||
registered_specs.extend(registrar(registry))
|
||||
return registered_specs
|
||||
178
src/plugin_runtime/hook_payloads.py
Normal file
178
src/plugin_runtime/hook_payloads.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""运行时 Hook 载荷序列化辅助。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Sequence
|
||||
|
||||
from src.chat.message_receive.message import SessionMessage
|
||||
from src.common.data_models.llm_service_data_models import PromptMessage
|
||||
from src.llm_models.payload_content.message import Message
|
||||
from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionInput, normalize_tool_options
|
||||
from src.plugin_runtime.host.message_utils import PluginMessageUtils
|
||||
|
||||
|
||||
def serialize_session_message(message: SessionMessage) -> Dict[str, Any]:
|
||||
"""将会话消息序列化为 Hook 可传输载荷。
|
||||
|
||||
Args:
|
||||
message: 待序列化的会话消息。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 可通过插件运行时传输的消息字典。
|
||||
"""
|
||||
|
||||
return dict(PluginMessageUtils._session_message_to_dict(message))
|
||||
|
||||
|
||||
def deserialize_session_message(raw_message: Any) -> SessionMessage:
|
||||
"""从 Hook 载荷恢复会话消息。
|
||||
|
||||
Args:
|
||||
raw_message: Hook 返回的消息字典。
|
||||
|
||||
Returns:
|
||||
SessionMessage: 恢复后的会话消息对象。
|
||||
|
||||
Raises:
|
||||
ValueError: 消息结构不合法时抛出。
|
||||
"""
|
||||
|
||||
if not isinstance(raw_message, dict):
|
||||
raise ValueError("Hook 返回的 `message` 必须是字典")
|
||||
return PluginMessageUtils._build_session_message_from_dict(raw_message)
|
||||
|
||||
|
||||
def serialize_tool_calls(tool_calls: Sequence[ToolCall] | None) -> List[Dict[str, Any]]:
|
||||
"""将工具调用列表序列化为 Hook 可传输载荷。
|
||||
|
||||
Args:
|
||||
tool_calls: 原始工具调用列表。
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 序列化后的工具调用列表。
|
||||
"""
|
||||
|
||||
if not tool_calls:
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"id": tool_call.call_id,
|
||||
"function": {
|
||||
"name": tool_call.func_name,
|
||||
"arguments": dict(tool_call.args or {}),
|
||||
},
|
||||
}
|
||||
for tool_call in tool_calls
|
||||
]
|
||||
|
||||
|
||||
def deserialize_tool_calls(raw_tool_calls: Any) -> List[ToolCall]:
|
||||
"""从 Hook 载荷恢复工具调用列表。
|
||||
|
||||
Args:
|
||||
raw_tool_calls: Hook 返回的工具调用列表。
|
||||
|
||||
Returns:
|
||||
List[ToolCall]: 恢复后的工具调用列表。
|
||||
|
||||
Raises:
|
||||
ValueError: 结构不合法时抛出。
|
||||
"""
|
||||
|
||||
if raw_tool_calls in (None, []):
|
||||
return []
|
||||
if not isinstance(raw_tool_calls, list):
|
||||
raise ValueError("Hook 返回的 `tool_calls` 必须是列表")
|
||||
|
||||
normalized_tool_calls: List[ToolCall] = []
|
||||
for raw_tool_call in raw_tool_calls:
|
||||
if not isinstance(raw_tool_call, dict):
|
||||
raise ValueError("Hook 返回的工具调用项必须是字典")
|
||||
|
||||
function_info = raw_tool_call.get("function", {})
|
||||
if isinstance(function_info, dict):
|
||||
function_name = function_info.get("name")
|
||||
function_arguments = function_info.get("arguments")
|
||||
else:
|
||||
function_name = raw_tool_call.get("name")
|
||||
function_arguments = raw_tool_call.get("arguments")
|
||||
|
||||
call_id = raw_tool_call.get("id") or raw_tool_call.get("call_id")
|
||||
if not isinstance(call_id, str) or not isinstance(function_name, str):
|
||||
raise ValueError("Hook 返回的工具调用缺少 `id` 或函数名称")
|
||||
|
||||
normalized_tool_calls.append(
|
||||
ToolCall(
|
||||
call_id=call_id,
|
||||
func_name=function_name,
|
||||
args=function_arguments if isinstance(function_arguments, dict) else {},
|
||||
)
|
||||
)
|
||||
return normalized_tool_calls
|
||||
|
||||
|
||||
def serialize_prompt_messages(messages: Sequence[Message]) -> List[PromptMessage]:
|
||||
"""将 LLM 消息列表序列化为 Hook 可传输载荷。
|
||||
|
||||
Args:
|
||||
messages: 原始 LLM 消息列表。
|
||||
|
||||
Returns:
|
||||
List[PromptMessage]: 序列化后的消息字典列表。
|
||||
"""
|
||||
|
||||
serialized_messages: List[PromptMessage] = []
|
||||
for message in messages:
|
||||
serialized_message: PromptMessage = {
|
||||
"role": message.role.value,
|
||||
"content": message.content,
|
||||
}
|
||||
if message.tool_call_id:
|
||||
serialized_message["tool_call_id"] = message.tool_call_id
|
||||
if message.tool_calls:
|
||||
serialized_message["tool_calls"] = serialize_tool_calls(message.tool_calls)
|
||||
serialized_messages.append(serialized_message)
|
||||
return serialized_messages
|
||||
|
||||
|
||||
def deserialize_prompt_messages(raw_messages: Any) -> List[Message]:
|
||||
"""从 Hook 载荷恢复 LLM 消息列表。
|
||||
|
||||
Args:
|
||||
raw_messages: Hook 返回的消息列表。
|
||||
|
||||
Returns:
|
||||
List[Message]: 恢复后的 LLM 消息列表。
|
||||
|
||||
Raises:
|
||||
ValueError: 结构不合法时抛出。
|
||||
"""
|
||||
|
||||
if not isinstance(raw_messages, list):
|
||||
raise ValueError("Hook 返回的 `messages` 必须是列表")
|
||||
|
||||
from src.services.llm_service import _build_message_from_dict
|
||||
|
||||
normalized_messages: List[Message] = []
|
||||
for raw_message in raw_messages:
|
||||
if not isinstance(raw_message, dict):
|
||||
raise ValueError("Hook 返回的消息项必须是字典")
|
||||
normalized_messages.append(_build_message_from_dict(raw_message))
|
||||
return normalized_messages
|
||||
|
||||
|
||||
def serialize_tool_definitions(tool_definitions: Sequence[ToolDefinitionInput]) -> List[Dict[str, Any]]:
|
||||
"""将工具定义列表序列化为 Hook 可传输载荷。
|
||||
|
||||
Args:
|
||||
tool_definitions: 原始工具定义列表。
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 序列化后的工具定义列表。
|
||||
"""
|
||||
|
||||
normalized_tool_options = normalize_tool_options(list(tool_definitions))
|
||||
if not normalized_tool_options:
|
||||
return []
|
||||
return [tool_option.to_openai_function_schema() for tool_option in normalized_tool_options]
|
||||
31
src/plugin_runtime/hook_schema_utils.py
Normal file
31
src/plugin_runtime/hook_schema_utils.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Hook 参数模型构造辅助。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, Sequence
|
||||
|
||||
|
||||
def build_object_schema(
|
||||
properties: Dict[str, Dict[str, Any]],
|
||||
*,
|
||||
required: Sequence[str] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""构造对象级 JSON Schema。
|
||||
|
||||
Args:
|
||||
properties: 字段定义映射。
|
||||
required: 必填字段名列表。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 标准化后的对象级 Schema。
|
||||
"""
|
||||
|
||||
schema: Dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": deepcopy(properties),
|
||||
}
|
||||
normalized_required = [str(item).strip() for item in (required or []) if str(item).strip()]
|
||||
if normalized_required:
|
||||
schema["required"] = normalized_required
|
||||
return schema
|
||||
@@ -18,9 +18,37 @@ import re
|
||||
from src.common.logger import get_logger
|
||||
from src.core.tooling import build_tool_detailed_description
|
||||
|
||||
from .hook_spec_registry import HookSpecRegistry
|
||||
|
||||
logger = get_logger("plugin_runtime.host.component_registry")
|
||||
|
||||
|
||||
class ComponentRegistrationError(ValueError):
|
||||
"""组件注册失败异常。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
component_name: str = "",
|
||||
component_type: str = "",
|
||||
plugin_id: str = "",
|
||||
) -> None:
|
||||
"""初始化组件注册失败异常。
|
||||
|
||||
Args:
|
||||
message: 原始错误信息。
|
||||
component_name: 组件名称。
|
||||
component_type: 组件类型。
|
||||
plugin_id: 插件 ID。
|
||||
"""
|
||||
|
||||
self.component_name = str(component_name or "").strip()
|
||||
self.component_type = str(component_type or "").strip()
|
||||
self.plugin_id = str(plugin_id or "").strip()
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ComponentTypes(str, Enum):
|
||||
ACTION = "ACTION"
|
||||
COMMAND = "COMMAND"
|
||||
@@ -359,7 +387,14 @@ class ComponentRegistry:
|
||||
供业务层查询可用组件、匹配命令、调度 action/event 等。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, hook_spec_registry: Optional[HookSpecRegistry] = None) -> None:
|
||||
"""初始化组件注册表。
|
||||
|
||||
Args:
|
||||
hook_spec_registry: 可选的 Hook 规格注册中心;提供后会在注册
|
||||
HookHandler 时执行规格校验。
|
||||
"""
|
||||
|
||||
# 全量索引
|
||||
self._components: Dict[str, ComponentEntry] = {} # full_name -> comp
|
||||
|
||||
@@ -370,6 +405,7 @@ class ComponentRegistry:
|
||||
|
||||
# 按插件索引
|
||||
self._by_plugin: Dict[str, List[ComponentEntry]] = {}
|
||||
self._hook_spec_registry = hook_spec_registry
|
||||
|
||||
@staticmethod
|
||||
def _convert_action_metadata_to_tool_metadata(
|
||||
@@ -475,77 +511,211 @@ class ComponentRegistry:
|
||||
type_dict.clear()
|
||||
self._by_plugin.clear()
|
||||
|
||||
# ====== 注册 / 注销 ======
|
||||
def register_component(self, name: str, component_type: str, plugin_id: str, metadata: Dict[str, Any]) -> bool:
|
||||
"""注册单个组件
|
||||
@staticmethod
|
||||
def _is_legacy_action_component(component: ComponentEntry) -> bool:
|
||||
"""判断组件是否为兼容旧 Action 的 Tool 条目。
|
||||
|
||||
Args:
|
||||
name: 组件名称(不含插件id前缀)
|
||||
component_type: 组件类型(如 `ACTION`、`COMMAND` 等)
|
||||
plugin_id: 插件id
|
||||
metadata: 组件元数据
|
||||
component: 待判断的组件条目。
|
||||
|
||||
Returns:
|
||||
success (bool): 是否成功注册(失败原因通常是组件类型无效)
|
||||
bool: 是否为兼容旧 Action 组件。
|
||||
"""
|
||||
|
||||
if not isinstance(component, ToolEntry):
|
||||
return False
|
||||
return str(component.metadata.get("legacy_component_type", "") or "").strip().upper() == "ACTION"
|
||||
|
||||
def _validate_hook_handler_entry(self, component: HookHandlerEntry) -> None:
|
||||
"""校验 HookHandler 是否满足已注册的 Hook 规格。
|
||||
|
||||
Args:
|
||||
component: 待校验的 HookHandler 条目。
|
||||
|
||||
Raises:
|
||||
ComponentRegistrationError: HookHandler 声明不合法时抛出。
|
||||
"""
|
||||
|
||||
if self._hook_spec_registry is None:
|
||||
return
|
||||
|
||||
hook_spec = self._hook_spec_registry.get_hook_spec(component.hook)
|
||||
if hook_spec is None:
|
||||
raise ComponentRegistrationError(
|
||||
f"HookHandler {component.full_name} 声明了未注册的 Hook: {component.hook}",
|
||||
component_name=component.name,
|
||||
component_type=component.component_type.value,
|
||||
plugin_id=component.plugin_id,
|
||||
)
|
||||
|
||||
if component.is_blocking and not hook_spec.allow_blocking:
|
||||
raise ComponentRegistrationError(
|
||||
f"HookHandler {component.full_name} 不能注册为 blocking:Hook {component.hook} 不允许 blocking 处理器",
|
||||
component_name=component.name,
|
||||
component_type=component.component_type.value,
|
||||
plugin_id=component.plugin_id,
|
||||
)
|
||||
|
||||
if component.is_observe and not hook_spec.allow_observe:
|
||||
raise ComponentRegistrationError(
|
||||
f"HookHandler {component.full_name} 不能注册为 observe:Hook {component.hook} 不允许 observe 处理器",
|
||||
component_name=component.name,
|
||||
component_type=component.component_type.value,
|
||||
plugin_id=component.plugin_id,
|
||||
)
|
||||
|
||||
if component.error_policy == "abort" and not hook_spec.allow_abort:
|
||||
raise ComponentRegistrationError(
|
||||
f"HookHandler {component.full_name} 不能使用 error_policy=abort:Hook {component.hook} 不允许 abort",
|
||||
component_name=component.name,
|
||||
component_type=component.component_type.value,
|
||||
plugin_id=component.plugin_id,
|
||||
)
|
||||
|
||||
def _build_component_entry(
|
||||
self,
|
||||
name: str,
|
||||
component_type: str,
|
||||
plugin_id: str,
|
||||
metadata: Dict[str, Any],
|
||||
) -> ComponentEntry:
|
||||
"""根据声明构造组件条目。
|
||||
|
||||
Args:
|
||||
name: 组件名称。
|
||||
component_type: 组件类型。
|
||||
plugin_id: 插件 ID。
|
||||
metadata: 组件元数据。
|
||||
|
||||
Returns:
|
||||
ComponentEntry: 已构造并完成校验的组件条目。
|
||||
|
||||
Raises:
|
||||
ComponentRegistrationError: 组件声明不合法时抛出。
|
||||
"""
|
||||
|
||||
try:
|
||||
normalized_type = self._normalize_component_type(component_type)
|
||||
normalized_metadata = dict(metadata)
|
||||
if normalized_type == ComponentTypes.ACTION:
|
||||
normalized_metadata = self._convert_action_metadata_to_tool_metadata(name, normalized_metadata)
|
||||
comp = ToolEntry(name, ComponentTypes.TOOL.value, plugin_id, normalized_metadata)
|
||||
component = ToolEntry(name, ComponentTypes.TOOL.value, plugin_id, normalized_metadata)
|
||||
elif normalized_type == ComponentTypes.COMMAND:
|
||||
comp = CommandEntry(name, normalized_type.value, plugin_id, normalized_metadata)
|
||||
component = CommandEntry(name, normalized_type.value, plugin_id, normalized_metadata)
|
||||
elif normalized_type == ComponentTypes.TOOL:
|
||||
comp = ToolEntry(name, normalized_type.value, plugin_id, normalized_metadata)
|
||||
component = ToolEntry(name, normalized_type.value, plugin_id, normalized_metadata)
|
||||
elif normalized_type == ComponentTypes.EVENT_HANDLER:
|
||||
comp = EventHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata)
|
||||
component = EventHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata)
|
||||
elif normalized_type == ComponentTypes.HOOK_HANDLER:
|
||||
comp = HookHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata)
|
||||
component = HookHandlerEntry(name, normalized_type.value, plugin_id, normalized_metadata)
|
||||
self._validate_hook_handler_entry(component)
|
||||
elif normalized_type == ComponentTypes.MESSAGE_GATEWAY:
|
||||
comp = MessageGatewayEntry(name, normalized_type.value, plugin_id, normalized_metadata)
|
||||
component = MessageGatewayEntry(name, normalized_type.value, plugin_id, normalized_metadata)
|
||||
else:
|
||||
raise ValueError(f"组件类型 {component_type} 不存在")
|
||||
except ValueError:
|
||||
logger.error(f"组件类型 {component_type} 不存在")
|
||||
return False
|
||||
raise ComponentRegistrationError(
|
||||
f"组件类型 {component_type} 不存在",
|
||||
component_name=name,
|
||||
component_type=component_type,
|
||||
plugin_id=plugin_id,
|
||||
)
|
||||
except ComponentRegistrationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ComponentRegistrationError(
|
||||
str(exc),
|
||||
component_name=name,
|
||||
component_type=component_type,
|
||||
plugin_id=plugin_id,
|
||||
) from exc
|
||||
|
||||
if comp.full_name in self._components:
|
||||
logger.warning(f"组件 {comp.full_name} 已存在,覆盖")
|
||||
old_comp = self._components[comp.full_name]
|
||||
# 从 _by_plugin 列表中移除旧条目,防止幽灵组件堆积
|
||||
old_list = self._by_plugin.get(old_comp.plugin_id)
|
||||
if old_list is not None:
|
||||
with contextlib.suppress(ValueError):
|
||||
old_list.remove(old_comp)
|
||||
# 从旧类型索引中移除,防止类型变更时幽灵残留
|
||||
if old_type_dict := self._by_type.get(old_comp.component_type):
|
||||
old_type_dict.pop(comp.full_name, None)
|
||||
return component
|
||||
|
||||
self._components[comp.full_name] = comp
|
||||
self._by_type[comp.component_type][comp.full_name] = comp
|
||||
self._by_plugin.setdefault(plugin_id, []).append(comp)
|
||||
def _remove_existing_component_entry(self, component: ComponentEntry) -> None:
|
||||
"""移除同名旧组件条目。
|
||||
|
||||
Args:
|
||||
component: 即将写入的新组件条目。
|
||||
"""
|
||||
|
||||
if component.full_name not in self._components:
|
||||
return
|
||||
|
||||
logger.warning(f"组件 {component.full_name} 已存在,覆盖")
|
||||
old_component = self._components[component.full_name]
|
||||
old_list = self._by_plugin.get(old_component.plugin_id)
|
||||
if old_list is not None:
|
||||
with contextlib.suppress(ValueError):
|
||||
old_list.remove(old_component)
|
||||
if old_type_dict := self._by_type.get(old_component.component_type):
|
||||
old_type_dict.pop(component.full_name, None)
|
||||
|
||||
def _add_component_entry(self, component: ComponentEntry) -> None:
|
||||
"""写入单个组件条目到全部索引。
|
||||
|
||||
Args:
|
||||
component: 待写入的组件条目。
|
||||
"""
|
||||
|
||||
self._remove_existing_component_entry(component)
|
||||
self._components[component.full_name] = component
|
||||
self._by_type[component.component_type][component.full_name] = component
|
||||
self._by_plugin.setdefault(component.plugin_id, []).append(component)
|
||||
|
||||
# ====== 注册 / 注销 ======
|
||||
def register_component(self, name: str, component_type: str, plugin_id: str, metadata: Dict[str, Any]) -> bool:
|
||||
"""注册单个组件。
|
||||
|
||||
Args:
|
||||
name: 组件名称(不含插件 ID 前缀)。
|
||||
component_type: 组件类型(如 ``ACTION``、``COMMAND`` 等)。
|
||||
plugin_id: 插件 ID。
|
||||
metadata: 组件元数据。
|
||||
|
||||
Returns:
|
||||
bool: 注册成功时恒为 ``True``。
|
||||
|
||||
Raises:
|
||||
ComponentRegistrationError: 组件声明不合法时抛出。
|
||||
"""
|
||||
|
||||
component = self._build_component_entry(name, component_type, plugin_id, metadata)
|
||||
self._add_component_entry(component)
|
||||
return True
|
||||
|
||||
def register_plugin_components(self, plugin_id: str, components: List[Dict[str, Any]]) -> int:
|
||||
"""批量注册一个插件的所有组件,返回成功注册数。
|
||||
"""批量替换一个插件的组件集合。
|
||||
|
||||
该方法会先完整校验所有组件声明,只有全部通过后才会替换旧组件,
|
||||
从而避免插件进入半注册状态。
|
||||
|
||||
Args:
|
||||
plugin_id (str): 插件id
|
||||
components (List[Dict[str, Any]]): 组件字典列表,每个组件包含 name, component_type, metadata 等字段
|
||||
plugin_id: 插件 ID。
|
||||
components: 组件声明字典列表。
|
||||
|
||||
Returns:
|
||||
count (int): 成功注册的组件数量
|
||||
int: 实际注册的组件数量。
|
||||
|
||||
Raises:
|
||||
ComponentRegistrationError: 任一组件声明不合法时抛出。
|
||||
"""
|
||||
count = 0
|
||||
for comp_data in components:
|
||||
ok = self.register_component(
|
||||
name=comp_data.get("name", ""),
|
||||
component_type=comp_data.get("component_type", ""),
|
||||
plugin_id=plugin_id,
|
||||
metadata=comp_data.get("metadata", {}),
|
||||
|
||||
prepared_components: List[ComponentEntry] = []
|
||||
for component_data in components:
|
||||
prepared_components.append(
|
||||
self._build_component_entry(
|
||||
name=str(component_data.get("name", "") or ""),
|
||||
component_type=str(component_data.get("component_type", "") or ""),
|
||||
plugin_id=plugin_id,
|
||||
metadata=component_data.get("metadata", {})
|
||||
if isinstance(component_data.get("metadata"), dict)
|
||||
else {},
|
||||
)
|
||||
)
|
||||
if ok:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
self.remove_components_by_plugin(plugin_id)
|
||||
for component in prepared_components:
|
||||
self._add_component_entry(component)
|
||||
return len(prepared_components)
|
||||
|
||||
def remove_components_by_plugin(self, plugin_id: str) -> int:
|
||||
"""移除某个插件的所有组件,返回移除数量。
|
||||
@@ -652,6 +822,17 @@ class ComponentRegistry:
|
||||
except ValueError:
|
||||
logger.error(f"组件类型 {component_type} 不存在")
|
||||
raise
|
||||
|
||||
if comp_type == ComponentTypes.ACTION:
|
||||
action_components = [
|
||||
component
|
||||
for component in self._by_type.get(ComponentTypes.TOOL, {}).values()
|
||||
if self._is_legacy_action_component(component)
|
||||
]
|
||||
if enabled_only:
|
||||
return [component for component in action_components if self.check_component_enabled(component, session_id)]
|
||||
return action_components
|
||||
|
||||
type_dict = self._by_type.get(comp_type, {})
|
||||
if enabled_only:
|
||||
return [c for c in type_dict.values() if self.check_component_enabled(c, session_id)]
|
||||
@@ -854,6 +1035,34 @@ class ComponentRegistry:
|
||||
tools.append(comp)
|
||||
return tools
|
||||
|
||||
def get_tools_for_llm(self, *, enabled_only: bool = True, session_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""兼容旧接口,返回可供 LLM 使用的工具条目列表。
|
||||
|
||||
Args:
|
||||
enabled_only: 是否仅返回启用的组件。
|
||||
session_id: 可选的会话 ID,若提供则考虑会话禁用状态。
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 兼容旧结构的工具组件字典列表。
|
||||
"""
|
||||
|
||||
return [
|
||||
{
|
||||
"name": tool.full_name,
|
||||
"description": tool.description,
|
||||
"parameters": (
|
||||
dict(tool.parameters_raw)
|
||||
if isinstance(tool.parameters_raw, dict) and tool.parameters_raw
|
||||
else tool._get_parameters_schema() or {}
|
||||
),
|
||||
"parameters_raw": tool.parameters_raw,
|
||||
"enabled": tool.enabled,
|
||||
"plugin_id": tool.plugin_id,
|
||||
}
|
||||
for tool in self.get_tools(enabled_only=enabled_only, session_id=session_id)
|
||||
if not self._is_legacy_action_component(tool)
|
||||
]
|
||||
|
||||
# ====== 统计信息 ======
|
||||
def get_stats(self) -> StatusDict:
|
||||
"""获取注册统计。
|
||||
@@ -863,9 +1072,21 @@ class ComponentRegistry:
|
||||
"""
|
||||
return StatusDict(
|
||||
total=len(self._components),
|
||||
action=len(self._by_type[ComponentTypes.ACTION]),
|
||||
action=len(
|
||||
[
|
||||
component
|
||||
for component in self._by_type.get(ComponentTypes.TOOL, {}).values()
|
||||
if self._is_legacy_action_component(component)
|
||||
]
|
||||
),
|
||||
command=len(self._by_type[ComponentTypes.COMMAND]),
|
||||
tool=len(self._by_type[ComponentTypes.TOOL]),
|
||||
tool=len(
|
||||
[
|
||||
component
|
||||
for component in self._by_type.get(ComponentTypes.TOOL, {}).values()
|
||||
if not self._is_legacy_action_component(component)
|
||||
]
|
||||
),
|
||||
event_handler=len(self._by_type[ComponentTypes.EVENT_HANDLER]),
|
||||
hook_handler=len(self._by_type[ComponentTypes.HOOK_HANDLER]),
|
||||
message_gateway=len(self._by_type[ComponentTypes.MESSAGE_GATEWAY]),
|
||||
|
||||
@@ -26,6 +26,8 @@ import contextlib
|
||||
from src.common.logger import get_logger
|
||||
from src.config.config import global_config
|
||||
|
||||
from .hook_spec_registry import HookSpec, HookSpecRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .component_registry import HookHandlerEntry
|
||||
from .supervisor import PluginRunnerSupervisor
|
||||
@@ -33,29 +35,6 @@ if TYPE_CHECKING:
|
||||
logger = get_logger("plugin_runtime.host.hook_dispatcher")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HookSpec:
|
||||
"""命名 Hook 的静态规格定义。
|
||||
|
||||
Attributes:
|
||||
name: Hook 的唯一名称。
|
||||
description: Hook 描述。
|
||||
default_timeout_ms: 默认超时毫秒数;为 `0` 时退回系统默认值。
|
||||
allow_blocking: 是否允许注册阻塞处理器。
|
||||
allow_observe: 是否允许注册观察处理器。
|
||||
allow_abort: 是否允许处理器中止当前 Hook 调用。
|
||||
allow_kwargs_mutation: 是否允许阻塞处理器修改 `kwargs`。
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
default_timeout_ms: int = 0
|
||||
allow_blocking: bool = True
|
||||
allow_observe: bool = True
|
||||
allow_abort: bool = True
|
||||
allow_kwargs_mutation: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HookHandlerExecutionResult:
|
||||
"""单个 HookHandler 的执行结果。
|
||||
@@ -121,17 +100,19 @@ class HookDispatcher:
|
||||
def __init__(
|
||||
self,
|
||||
supervisors_provider: Optional[Callable[[], Sequence["PluginRunnerSupervisor"]]] = None,
|
||||
hook_spec_registry: Optional[HookSpecRegistry] = None,
|
||||
) -> None:
|
||||
"""初始化 Hook 分发器。
|
||||
|
||||
Args:
|
||||
supervisors_provider: 可选的 Supervisor 提供器。若调用 `invoke_hook()`
|
||||
时未显式传入 `supervisors`,则使用该回调获取目标 Supervisor 列表。
|
||||
hook_spec_registry: 可选的 Hook 规格注册中心;留空时使用独立注册中心。
|
||||
"""
|
||||
|
||||
self._background_tasks: Set[asyncio.Task[Any]] = set()
|
||||
self._hook_specs: Dict[str, HookSpec] = {}
|
||||
self._supervisors_provider = supervisors_provider
|
||||
self._hook_spec_registry = hook_spec_registry or HookSpecRegistry()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""停止分发器并取消所有未完成的观察任务。"""
|
||||
@@ -148,16 +129,7 @@ class HookDispatcher:
|
||||
spec: 需要注册的 Hook 规格。
|
||||
"""
|
||||
|
||||
normalized_name = self._normalize_hook_name(spec.name)
|
||||
self._hook_specs[normalized_name] = HookSpec(
|
||||
name=normalized_name,
|
||||
description=spec.description,
|
||||
default_timeout_ms=max(int(spec.default_timeout_ms), 0),
|
||||
allow_blocking=bool(spec.allow_blocking),
|
||||
allow_observe=bool(spec.allow_observe),
|
||||
allow_abort=bool(spec.allow_abort),
|
||||
allow_kwargs_mutation=bool(spec.allow_kwargs_mutation),
|
||||
)
|
||||
self._hook_spec_registry.register_hook_spec(spec)
|
||||
|
||||
def register_hook_specs(self, specs: Sequence[HookSpec]) -> None:
|
||||
"""批量注册命名 Hook 规格。
|
||||
@@ -180,14 +152,37 @@ class HookDispatcher:
|
||||
"""
|
||||
|
||||
normalized_name = self._normalize_hook_name(hook_name)
|
||||
if normalized_name in self._hook_specs:
|
||||
return self._hook_specs[normalized_name]
|
||||
registered_spec = self._hook_spec_registry.get_hook_spec(normalized_name)
|
||||
if registered_spec is not None:
|
||||
return registered_spec
|
||||
|
||||
return HookSpec(
|
||||
name=normalized_name,
|
||||
parameters_schema={},
|
||||
default_timeout_ms=self._get_default_timeout_ms(),
|
||||
)
|
||||
|
||||
def unregister_hook_spec(self, hook_name: str) -> bool:
|
||||
"""注销指定命名 Hook 规格。
|
||||
|
||||
Args:
|
||||
hook_name: 目标 Hook 名称。
|
||||
|
||||
Returns:
|
||||
bool: 是否成功注销。
|
||||
"""
|
||||
|
||||
return self._hook_spec_registry.unregister_hook_spec(hook_name)
|
||||
|
||||
def list_hook_specs(self) -> List[HookSpec]:
|
||||
"""返回当前全部显式注册的 Hook 规格。
|
||||
|
||||
Returns:
|
||||
List[HookSpec]: 已注册 Hook 规格列表。
|
||||
"""
|
||||
|
||||
return self._hook_spec_registry.list_hook_specs()
|
||||
|
||||
async def invoke_hook(
|
||||
self,
|
||||
hook_name: str,
|
||||
|
||||
190
src/plugin_runtime/host/hook_spec_registry.py
Normal file
190
src/plugin_runtime/host/hook_spec_registry.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""命名 Hook 规格注册中心。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HookSpec:
|
||||
"""命名 Hook 的静态规格定义。
|
||||
|
||||
Attributes:
|
||||
name: Hook 的唯一名称。
|
||||
description: Hook 描述。
|
||||
parameters_schema: Hook 参数模型,使用对象级 JSON Schema 表示。
|
||||
default_timeout_ms: 默认超时毫秒数;为 ``0`` 时退回系统默认值。
|
||||
allow_blocking: 是否允许注册阻塞处理器。
|
||||
allow_observe: 是否允许注册观察处理器。
|
||||
allow_abort: 是否允许处理器中止当前 Hook 调用。
|
||||
allow_kwargs_mutation: 是否允许阻塞处理器修改 ``kwargs``。
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
parameters_schema: Dict[str, Any] = field(default_factory=dict)
|
||||
default_timeout_ms: int = 0
|
||||
allow_blocking: bool = True
|
||||
allow_observe: bool = True
|
||||
allow_abort: bool = True
|
||||
allow_kwargs_mutation: bool = True
|
||||
|
||||
|
||||
class HookSpecRegistry:
|
||||
"""命名 Hook 规格注册中心。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""初始化 Hook 规格注册中心。"""
|
||||
|
||||
self._hook_specs: Dict[str, HookSpec] = {}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_hook_name(hook_name: str) -> str:
|
||||
"""规范化 Hook 名称。
|
||||
|
||||
Args:
|
||||
hook_name: 原始 Hook 名称。
|
||||
|
||||
Returns:
|
||||
str: 规范化后的 Hook 名称。
|
||||
|
||||
Raises:
|
||||
ValueError: Hook 名称为空时抛出。
|
||||
"""
|
||||
|
||||
normalized_name = str(hook_name or "").strip()
|
||||
if not normalized_name:
|
||||
raise ValueError("Hook 名称不能为空")
|
||||
return normalized_name
|
||||
|
||||
@staticmethod
|
||||
def _normalize_parameters_schema(raw_schema: Any) -> Dict[str, Any]:
|
||||
"""规范化 Hook 参数模型。
|
||||
|
||||
Args:
|
||||
raw_schema: 原始参数模型。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 规范化后的对象级 JSON Schema。
|
||||
|
||||
Raises:
|
||||
ValueError: 参数模型不是合法对象级 Schema 时抛出。
|
||||
"""
|
||||
|
||||
if raw_schema is None:
|
||||
return {}
|
||||
if not isinstance(raw_schema, dict):
|
||||
raise ValueError("Hook 参数模型必须是字典")
|
||||
if not raw_schema:
|
||||
return {}
|
||||
|
||||
normalized_schema = deepcopy(raw_schema)
|
||||
schema_type = normalized_schema.get("type")
|
||||
properties = normalized_schema.get("properties")
|
||||
if schema_type not in {"", None, "object"} and properties is None:
|
||||
raise ValueError("Hook 参数模型必须是 object 类型或属性映射")
|
||||
if schema_type in {"", None} and properties is None:
|
||||
normalized_schema = {
|
||||
"type": "object",
|
||||
"properties": normalized_schema,
|
||||
}
|
||||
elif schema_type in {"", None}:
|
||||
normalized_schema["type"] = "object"
|
||||
|
||||
if normalized_schema.get("type") != "object":
|
||||
raise ValueError("Hook 参数模型必须是 object 类型")
|
||||
return normalized_schema
|
||||
|
||||
@classmethod
|
||||
def _normalize_spec(cls, spec: HookSpec) -> HookSpec:
|
||||
"""规范化 Hook 规格对象。
|
||||
|
||||
Args:
|
||||
spec: 原始 Hook 规格。
|
||||
|
||||
Returns:
|
||||
HookSpec: 规范化后的 Hook 规格副本。
|
||||
"""
|
||||
|
||||
return HookSpec(
|
||||
name=cls._normalize_hook_name(spec.name),
|
||||
description=str(spec.description or "").strip(),
|
||||
parameters_schema=cls._normalize_parameters_schema(spec.parameters_schema),
|
||||
default_timeout_ms=max(int(spec.default_timeout_ms), 0),
|
||||
allow_blocking=bool(spec.allow_blocking),
|
||||
allow_observe=bool(spec.allow_observe),
|
||||
allow_abort=bool(spec.allow_abort),
|
||||
allow_kwargs_mutation=bool(spec.allow_kwargs_mutation),
|
||||
)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""清空全部 Hook 规格。"""
|
||||
|
||||
self._hook_specs.clear()
|
||||
|
||||
def register_hook_spec(self, spec: HookSpec) -> HookSpec:
|
||||
"""注册单个 Hook 规格。
|
||||
|
||||
Args:
|
||||
spec: 需要注册的 Hook 规格。
|
||||
|
||||
Returns:
|
||||
HookSpec: 规范化后实际注册的 Hook 规格。
|
||||
"""
|
||||
|
||||
normalized_spec = self._normalize_spec(spec)
|
||||
self._hook_specs[normalized_spec.name] = normalized_spec
|
||||
return normalized_spec
|
||||
|
||||
def register_hook_specs(self, specs: Sequence[HookSpec]) -> List[HookSpec]:
|
||||
"""批量注册 Hook 规格。
|
||||
|
||||
Args:
|
||||
specs: 需要注册的 Hook 规格列表。
|
||||
|
||||
Returns:
|
||||
List[HookSpec]: 规范化后实际注册的 Hook 规格列表。
|
||||
"""
|
||||
|
||||
return [self.register_hook_spec(spec) for spec in specs]
|
||||
|
||||
def unregister_hook_spec(self, hook_name: str) -> bool:
|
||||
"""注销指定 Hook 规格。
|
||||
|
||||
Args:
|
||||
hook_name: 目标 Hook 名称。
|
||||
|
||||
Returns:
|
||||
bool: 是否成功删除。
|
||||
"""
|
||||
|
||||
normalized_name = self._normalize_hook_name(hook_name)
|
||||
return self._hook_specs.pop(normalized_name, None) is not None
|
||||
|
||||
def get_hook_spec(self, hook_name: str) -> Optional[HookSpec]:
|
||||
"""获取指定 Hook 的显式规格。
|
||||
|
||||
Args:
|
||||
hook_name: 目标 Hook 名称。
|
||||
|
||||
Returns:
|
||||
Optional[HookSpec]: 已注册时返回规格副本,否则返回 ``None``。
|
||||
"""
|
||||
|
||||
normalized_name = self._normalize_hook_name(hook_name)
|
||||
spec = self._hook_specs.get(normalized_name)
|
||||
return None if spec is None else self._normalize_spec(spec)
|
||||
|
||||
def list_hook_specs(self) -> List[HookSpec]:
|
||||
"""返回当前全部 Hook 规格。
|
||||
|
||||
Returns:
|
||||
List[HookSpec]: 按 Hook 名称升序排列的规格副本列表。
|
||||
"""
|
||||
|
||||
return [
|
||||
self._normalize_spec(spec)
|
||||
for _, spec in sorted(self._hook_specs.items(), key=lambda item: item[0])
|
||||
]
|
||||
@@ -14,6 +14,7 @@ from src.platform_io import DriverKind, InboundMessageEnvelope, RouteBinding, Ro
|
||||
from src.platform_io.drivers import PluginPlatformDriver
|
||||
from src.platform_io.route_key_factory import RouteKeyFactory
|
||||
from src.plugin_runtime import (
|
||||
ENV_BLOCKED_PLUGIN_REASONS,
|
||||
ENV_EXTERNAL_PLUGIN_IDS,
|
||||
ENV_GLOBAL_CONFIG_SNAPSHOT,
|
||||
ENV_HOST_VERSION,
|
||||
@@ -27,6 +28,8 @@ from src.plugin_runtime.protocol.envelope import (
|
||||
ConfigUpdatedPayload,
|
||||
Envelope,
|
||||
HealthPayload,
|
||||
InspectPluginConfigPayload,
|
||||
InspectPluginConfigResultPayload,
|
||||
MessageGatewayStateUpdatePayload,
|
||||
MessageGatewayStateUpdateResultPayload,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -39,6 +42,8 @@ from src.plugin_runtime.protocol.envelope import (
|
||||
RunnerReadyPayload,
|
||||
ShutdownPayload,
|
||||
UnregisterPluginPayload,
|
||||
ValidatePluginConfigPayload,
|
||||
ValidatePluginConfigResultPayload,
|
||||
)
|
||||
from src.plugin_runtime.protocol.codec import MsgPackCodec
|
||||
from src.plugin_runtime.protocol.errors import ErrorCode, RPCError
|
||||
@@ -50,6 +55,7 @@ from .capability_service import CapabilityService
|
||||
from .component_registry import ComponentRegistry
|
||||
from .event_dispatcher import EventDispatcher
|
||||
from .hook_dispatcher import HookDispatchResult, HookDispatcher
|
||||
from .hook_spec_registry import HookSpecRegistry
|
||||
from .logger_bridge import RunnerLogBridge
|
||||
from .message_gateway import MessageGateway
|
||||
from .rpc_server import RPCServer
|
||||
@@ -59,6 +65,7 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = get_logger("plugin_runtime.host.runner_manager")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _MessageGatewayRuntimeState:
|
||||
"""保存消息网关当前的运行时连接状态。"""
|
||||
@@ -81,6 +88,7 @@ class PluginRunnerSupervisor:
|
||||
self,
|
||||
plugin_dirs: Optional[List[Path]] = None,
|
||||
group_name: str = "third_party",
|
||||
hook_spec_registry: Optional[HookSpecRegistry] = None,
|
||||
socket_path: Optional[str] = None,
|
||||
health_check_interval_sec: Optional[float] = None,
|
||||
max_restart_attempts: Optional[int] = None,
|
||||
@@ -91,6 +99,7 @@ class PluginRunnerSupervisor:
|
||||
Args:
|
||||
plugin_dirs: 由当前 Runner 负责加载的插件目录列表。
|
||||
group_name: 当前 Supervisor 所属运行时分组名称。
|
||||
hook_spec_registry: 可选的共享 Hook 规格注册中心。
|
||||
socket_path: 自定义 IPC 地址;留空时由传输层自动生成。
|
||||
health_check_interval_sec: 健康检查间隔,单位秒。
|
||||
max_restart_attempts: 自动重启 Runner 的最大次数。
|
||||
@@ -100,18 +109,19 @@ class PluginRunnerSupervisor:
|
||||
self._group_name: str = str(group_name or "third_party").strip() or "third_party"
|
||||
self._plugin_dirs: List[Path] = plugin_dirs or []
|
||||
self._health_interval: float = health_check_interval_sec or runtime_config.health_check_interval_sec or 30.0
|
||||
self._runner_spawn_timeout: float = (
|
||||
runner_spawn_timeout_sec or runtime_config.runner_spawn_timeout_sec or 30.0
|
||||
)
|
||||
self._runner_spawn_timeout: float = runner_spawn_timeout_sec or runtime_config.runner_spawn_timeout_sec or 30.0
|
||||
self._max_restart_attempts: int = max_restart_attempts or runtime_config.max_restart_attempts or 3
|
||||
|
||||
self._transport = create_transport_server(socket_path=socket_path)
|
||||
self._authorization = AuthorizationManager()
|
||||
self._capability_service = CapabilityService(self._authorization)
|
||||
self._api_registry = APIRegistry()
|
||||
self._component_registry = ComponentRegistry()
|
||||
self._component_registry = ComponentRegistry(hook_spec_registry=hook_spec_registry)
|
||||
self._event_dispatcher = EventDispatcher(self._component_registry)
|
||||
self._hook_dispatcher = HookDispatcher(lambda: [self])
|
||||
self._hook_dispatcher = HookDispatcher(
|
||||
lambda: [self],
|
||||
hook_spec_registry=hook_spec_registry,
|
||||
)
|
||||
self._message_gateway = MessageGateway(self._component_registry)
|
||||
self._log_bridge = RunnerLogBridge()
|
||||
|
||||
@@ -122,6 +132,7 @@ class PluginRunnerSupervisor:
|
||||
self._registered_plugins: Dict[str, RegisterPluginPayload] = {}
|
||||
self._message_gateway_states: Dict[str, Dict[str, _MessageGatewayRuntimeState]] = {}
|
||||
self._external_available_plugins: Dict[str, str] = {}
|
||||
self._blocked_plugin_reasons: Dict[str, str] = {}
|
||||
self._runner_ready_events: asyncio.Event = asyncio.Event()
|
||||
self._runner_ready_payloads: RunnerReadyPayload = RunnerReadyPayload()
|
||||
self._health_task: Optional[asyncio.Task[None]] = None
|
||||
@@ -200,9 +211,19 @@ class PluginRunnerSupervisor:
|
||||
Returns:
|
||||
Dict[str, str]: 已注册插件版本映射,键为插件 ID,值为插件版本。
|
||||
"""
|
||||
return {
|
||||
plugin_id: registration.plugin_version
|
||||
for plugin_id, registration in self._registered_plugins.items()
|
||||
return {plugin_id: registration.plugin_version for plugin_id, registration in self._registered_plugins.items()}
|
||||
|
||||
def set_blocked_plugin_reasons(self, blocked_plugin_reasons: Dict[str, str]) -> None:
|
||||
"""设置当前 Runner 启动时应拒绝加载的插件列表。
|
||||
|
||||
Args:
|
||||
blocked_plugin_reasons: 需要拒绝加载的插件及原因映射。
|
||||
"""
|
||||
|
||||
self._blocked_plugin_reasons = {
|
||||
str(plugin_id or "").strip(): str(reason or "").strip()
|
||||
for plugin_id, reason in blocked_plugin_reasons.items()
|
||||
if str(plugin_id or "").strip() and str(reason or "").strip()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -550,6 +571,82 @@ class PluginRunnerSupervisor:
|
||||
|
||||
return bool(response.payload.get("acknowledged", False))
|
||||
|
||||
async def validate_plugin_config(self, plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""请求 Runner 使用插件自身配置模型校验配置。
|
||||
|
||||
Args:
|
||||
plugin_id: 目标插件 ID。
|
||||
config_data: 待校验的配置内容。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 插件模型归一化后的配置字典。
|
||||
|
||||
Raises:
|
||||
ValueError: 插件拒绝该配置或校验失败时抛出。
|
||||
"""
|
||||
|
||||
payload = ValidatePluginConfigPayload(config_data=config_data)
|
||||
try:
|
||||
response = await self._rpc_server.send_request(
|
||||
"plugin.validate_config",
|
||||
plugin_id=plugin_id,
|
||||
payload=payload.model_dump(),
|
||||
timeout_ms=10000,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"插件配置校验请求失败: {exc}") from exc
|
||||
|
||||
if response.error:
|
||||
raise ValueError(str(response.error.get("message", "插件配置校验失败")))
|
||||
|
||||
result = ValidatePluginConfigResultPayload.model_validate(response.payload)
|
||||
if not result.success:
|
||||
raise ValueError("插件配置校验失败")
|
||||
return dict(result.normalized_config)
|
||||
|
||||
async def inspect_plugin_config(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config_data: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
use_provided_config: bool = False,
|
||||
) -> InspectPluginConfigResultPayload:
|
||||
"""请求 Runner 解析插件配置元数据。
|
||||
|
||||
Args:
|
||||
plugin_id: 目标插件 ID。
|
||||
config_data: 可选的配置内容。
|
||||
use_provided_config: 是否优先使用传入配置而不是磁盘配置。
|
||||
|
||||
Returns:
|
||||
InspectPluginConfigResultPayload: 插件配置解析结果。
|
||||
|
||||
Raises:
|
||||
ValueError: Runner 无法解析插件或返回了错误响应时抛出。
|
||||
"""
|
||||
|
||||
payload = InspectPluginConfigPayload(
|
||||
config_data=config_data or {},
|
||||
use_provided_config=use_provided_config,
|
||||
)
|
||||
try:
|
||||
response = await self._rpc_server.send_request(
|
||||
"plugin.inspect_config",
|
||||
plugin_id=plugin_id,
|
||||
payload=payload.model_dump(),
|
||||
timeout_ms=10000,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"插件配置解析请求失败: {exc}") from exc
|
||||
|
||||
if response.error:
|
||||
raise ValueError(str(response.error.get("message", "插件配置解析失败")))
|
||||
|
||||
result = InspectPluginConfigResultPayload.model_validate(response.payload)
|
||||
if not result.success:
|
||||
raise ValueError("插件配置解析失败")
|
||||
return result
|
||||
|
||||
def get_config_reload_subscribers(self, scope: str) -> List[str]:
|
||||
"""返回订阅指定全局配置广播的插件列表。
|
||||
|
||||
@@ -608,6 +705,7 @@ class PluginRunnerSupervisor:
|
||||
Raises:
|
||||
TimeoutError: 在超时时间内 Runner 未完成初始化。
|
||||
"""
|
||||
|
||||
async def wait_for_ready() -> RunnerReadyPayload:
|
||||
"""轮询等待 Runner 上报就绪。"""
|
||||
while True:
|
||||
@@ -681,15 +779,25 @@ class PluginRunnerSupervisor:
|
||||
|
||||
component_declarations = [component.model_dump() for component in payload.components]
|
||||
runtime_components, api_components = self._split_component_declarations(component_declarations)
|
||||
self._component_registry.remove_components_by_plugin(payload.plugin_id)
|
||||
self._api_registry.remove_apis_by_plugin(payload.plugin_id)
|
||||
await self._unregister_all_message_gateway_drivers_for_plugin(payload.plugin_id)
|
||||
try:
|
||||
registered_count = self._component_registry.register_plugin_components(
|
||||
payload.plugin_id,
|
||||
runtime_components,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"插件 {payload.plugin_id} 组件注册失败: {exc}")
|
||||
return envelope.make_error_response(
|
||||
ErrorCode.E_BAD_PAYLOAD.value,
|
||||
str(exc),
|
||||
details={
|
||||
"plugin_id": payload.plugin_id,
|
||||
"component_count": len(runtime_components),
|
||||
},
|
||||
)
|
||||
|
||||
registered_count = self._component_registry.register_plugin_components(
|
||||
payload.plugin_id,
|
||||
runtime_components,
|
||||
)
|
||||
self._api_registry.remove_apis_by_plugin(payload.plugin_id)
|
||||
registered_api_count = self._api_registry.register_plugin_apis(payload.plugin_id, api_components)
|
||||
await self._unregister_all_message_gateway_drivers_for_plugin(payload.plugin_id)
|
||||
self._registered_plugins[payload.plugin_id] = payload
|
||||
self._message_gateway_states[payload.plugin_id] = {}
|
||||
|
||||
@@ -1058,7 +1166,9 @@ class PluginRunnerSupervisor:
|
||||
route_key = RouteKey(platform=platform)
|
||||
|
||||
route_account_id, route_scope = RouteKeyFactory.extract_components(route_metadata)
|
||||
account_id = route_key.account_id or route_account_id or runtime_state.account_id or gateway_entry.account_id or None
|
||||
account_id = (
|
||||
route_key.account_id or route_account_id or runtime_state.account_id or gateway_entry.account_id or None
|
||||
)
|
||||
scope = route_key.scope or route_scope or runtime_state.scope or gateway_entry.scope or None
|
||||
return RouteKey(
|
||||
platform=platform,
|
||||
@@ -1208,6 +1318,7 @@ class PluginRunnerSupervisor:
|
||||
global_config_snapshot = config_manager.get_global_config().model_dump(mode="json")
|
||||
global_config_snapshot["model"] = config_manager.get_model_config().model_dump(mode="json")
|
||||
return {
|
||||
ENV_BLOCKED_PLUGIN_REASONS: json.dumps(self._blocked_plugin_reasons, ensure_ascii=False),
|
||||
ENV_EXTERNAL_PLUGIN_IDS: json.dumps(self._external_available_plugins, ensure_ascii=False),
|
||||
ENV_GLOBAL_CONFIG_SNAPSHOT: json.dumps(global_config_snapshot, ensure_ascii=False),
|
||||
ENV_HOST_VERSION: PROTOCOL_VERSION,
|
||||
|
||||
@@ -8,10 +8,25 @@
|
||||
5. 提供统一的能力实现注册接口,使插件可以调用主程序功能
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Coroutine,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
|
||||
import tomlkit
|
||||
|
||||
@@ -23,10 +38,15 @@ from src.plugin_runtime.capabilities import (
|
||||
RuntimeComponentCapabilityMixin,
|
||||
RuntimeCoreCapabilityMixin,
|
||||
RuntimeDataCapabilityMixin,
|
||||
RuntimeRenderCapabilityMixin,
|
||||
)
|
||||
from src.plugin_runtime.capabilities.registry import register_capability_impls
|
||||
from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult, HookDispatcher, HookSpec
|
||||
from src.plugin_runtime.dependency_pipeline import PluginDependencyPipeline
|
||||
from src.plugin_runtime.hook_catalog import register_builtin_hook_specs
|
||||
from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult, HookDispatcher
|
||||
from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry
|
||||
from src.plugin_runtime.host.message_utils import MessageDict, PluginMessageUtils
|
||||
from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -50,10 +70,19 @@ _EVENT_TYPE_MAP: Dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DependencySyncState:
|
||||
"""表示一次插件依赖同步后的状态。"""
|
||||
|
||||
blocked_changed_plugin_ids: Set[str]
|
||||
environment_changed: bool
|
||||
|
||||
|
||||
class PluginRuntimeManager(
|
||||
RuntimeCoreCapabilityMixin,
|
||||
RuntimeDataCapabilityMixin,
|
||||
RuntimeComponentCapabilityMixin,
|
||||
RuntimeRenderCapabilityMixin,
|
||||
):
|
||||
"""插件运行时管理器(单例)
|
||||
|
||||
@@ -71,10 +100,17 @@ class PluginRuntimeManager(
|
||||
self._plugin_source_watcher_subscription_id: Optional[str] = None
|
||||
self._plugin_config_watcher_subscriptions: Dict[str, Tuple[Path, str]] = {}
|
||||
self._plugin_path_cache: Dict[str, Path] = {}
|
||||
self._manifest_validator: ManifestValidator = ManifestValidator()
|
||||
self._manifest_validator: ManifestValidator = ManifestValidator(validate_python_package_dependencies=False)
|
||||
self._plugin_dependency_pipeline: PluginDependencyPipeline = PluginDependencyPipeline()
|
||||
self._blocked_plugin_reasons: Dict[str, str] = {}
|
||||
self._config_reload_callback: Callable[[Sequence[str]], Awaitable[None]] = self._handle_main_config_reload
|
||||
self._config_reload_callback_registered: bool = False
|
||||
self._hook_dispatcher: HookDispatcher = HookDispatcher(lambda: self.supervisors)
|
||||
self._hook_spec_registry: HookSpecRegistry = HookSpecRegistry()
|
||||
self._builtin_hook_specs_registered: bool = False
|
||||
self._hook_dispatcher: HookDispatcher = HookDispatcher(
|
||||
lambda: self.supervisors,
|
||||
hook_spec_registry=self._hook_spec_registry,
|
||||
)
|
||||
|
||||
async def _dispatch_platform_inbound(self, envelope: InboundMessageEnvelope) -> None:
|
||||
"""接收 Platform IO 审核后的入站消息并送入主消息链。
|
||||
@@ -109,7 +145,7 @@ class PluginRuntimeManager(
|
||||
@classmethod
|
||||
def _discover_plugin_dependency_map(cls, plugin_dirs: Iterable[Path]) -> Dict[str, List[str]]:
|
||||
"""扫描指定插件目录集合,返回 ``plugin_id -> dependencies`` 映射。"""
|
||||
validator = ManifestValidator()
|
||||
validator = ManifestValidator(validate_python_package_dependencies=False)
|
||||
return validator.build_plugin_dependency_map(plugin_dirs)
|
||||
|
||||
@classmethod
|
||||
@@ -142,6 +178,233 @@ class PluginRuntimeManager(
|
||||
return ["third_party", "builtin"]
|
||||
return ["builtin", "third_party"]
|
||||
|
||||
@staticmethod
|
||||
def _instantiate_supervisor(supervisor_cls: Any, **kwargs: Any) -> Any:
|
||||
"""兼容不同构造签名地实例化 Supervisor。
|
||||
|
||||
Args:
|
||||
supervisor_cls: 目标 Supervisor 类。
|
||||
**kwargs: 期望传入的构造参数。
|
||||
|
||||
Returns:
|
||||
Any: 实例化后的 Supervisor。
|
||||
"""
|
||||
|
||||
signature = inspect.signature(supervisor_cls)
|
||||
accepts_var_keyword = any(
|
||||
parameter.kind == inspect.Parameter.VAR_KEYWORD
|
||||
for parameter in signature.parameters.values()
|
||||
)
|
||||
if accepts_var_keyword:
|
||||
return supervisor_cls(**kwargs)
|
||||
|
||||
supported_kwargs = {
|
||||
key: value
|
||||
for key, value in kwargs.items()
|
||||
if key in signature.parameters
|
||||
}
|
||||
return supervisor_cls(**supported_kwargs)
|
||||
|
||||
def _resolve_runtime_plugin_dirs(self) -> Tuple[List[Path], List[Path]]:
|
||||
"""解析当前运行时应管理的插件根目录。
|
||||
|
||||
Returns:
|
||||
Tuple[List[Path], List[Path]]: 内置插件目录列表与第三方插件目录列表。
|
||||
"""
|
||||
|
||||
return self._get_builtin_plugin_dirs(), self._get_third_party_plugin_dirs()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_supervisor_socket_paths() -> Tuple[Optional[str], Optional[str]]:
|
||||
"""解析内置与第三方 Supervisor 的 IPC 地址。
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[str], Optional[str]]: 内置 Runner 与第三方 Runner 的 socket 地址。
|
||||
"""
|
||||
|
||||
runtime_config = config_manager.get_global_config().plugin_runtime
|
||||
socket_path_base = runtime_config.ipc_socket_path or None
|
||||
builtin_socket = f"{socket_path_base}-builtin" if socket_path_base else None
|
||||
third_party_socket = f"{socket_path_base}-third_party" if socket_path_base else None
|
||||
return builtin_socket, third_party_socket
|
||||
|
||||
def _apply_blocked_plugin_reasons_to_supervisors(self) -> None:
|
||||
"""将当前阻止加载插件列表同步到全部 Supervisor。"""
|
||||
|
||||
for supervisor in self.supervisors:
|
||||
set_blocked_plugin_reasons = getattr(supervisor, "set_blocked_plugin_reasons", None)
|
||||
if callable(set_blocked_plugin_reasons):
|
||||
set_blocked_plugin_reasons(self._blocked_plugin_reasons)
|
||||
|
||||
def _set_blocked_plugin_reasons(self, blocked_plugin_reasons: Dict[str, str]) -> Set[str]:
|
||||
"""更新 Host 侧维护的阻止加载插件列表。
|
||||
|
||||
Args:
|
||||
blocked_plugin_reasons: 最新的阻止加载插件及原因映射。
|
||||
|
||||
Returns:
|
||||
Set[str]: 本次发生状态变化的插件 ID 集合。
|
||||
"""
|
||||
|
||||
normalized_reasons = {
|
||||
str(plugin_id or "").strip(): str(reason or "").strip()
|
||||
for plugin_id, reason in blocked_plugin_reasons.items()
|
||||
if str(plugin_id or "").strip() and str(reason or "").strip()
|
||||
}
|
||||
changed_plugin_ids = {
|
||||
plugin_id
|
||||
for plugin_id in set(self._blocked_plugin_reasons) | set(normalized_reasons)
|
||||
if self._blocked_plugin_reasons.get(plugin_id) != normalized_reasons.get(plugin_id)
|
||||
}
|
||||
self._blocked_plugin_reasons = normalized_reasons
|
||||
self._apply_blocked_plugin_reasons_to_supervisors()
|
||||
return changed_plugin_ids
|
||||
|
||||
async def _sync_plugin_dependencies(self, plugin_dirs: Sequence[Path]) -> DependencySyncState:
|
||||
"""执行插件依赖同步,并刷新阻止加载插件列表。
|
||||
|
||||
Args:
|
||||
plugin_dirs: 当前需要参与分析的插件根目录列表。
|
||||
|
||||
Returns:
|
||||
DependencySyncState: 同步后的环境变更状态与阻止列表变化集合。
|
||||
"""
|
||||
|
||||
result = await self._plugin_dependency_pipeline.execute(plugin_dirs)
|
||||
changed_plugin_ids = self._set_blocked_plugin_reasons(result.blocked_plugin_reasons)
|
||||
return DependencySyncState(
|
||||
blocked_changed_plugin_ids=changed_plugin_ids,
|
||||
environment_changed=result.environment_changed,
|
||||
)
|
||||
|
||||
def _build_supervisors(self, builtin_dirs: Sequence[Path], third_party_dirs: Sequence[Path]) -> None:
|
||||
"""根据目录列表创建当前运行时所需的 Supervisor。
|
||||
|
||||
Args:
|
||||
builtin_dirs: 内置插件目录列表。
|
||||
third_party_dirs: 第三方插件目录列表。
|
||||
"""
|
||||
|
||||
from src.plugin_runtime.host.supervisor import PluginSupervisor
|
||||
|
||||
builtin_socket, third_party_socket = self._resolve_supervisor_socket_paths()
|
||||
self._builtin_supervisor = None
|
||||
self._third_party_supervisor = None
|
||||
|
||||
if builtin_dirs:
|
||||
builtin_supervisor = self._instantiate_supervisor(
|
||||
PluginSupervisor,
|
||||
plugin_dirs=list(builtin_dirs),
|
||||
group_name="builtin",
|
||||
hook_spec_registry=self._hook_spec_registry,
|
||||
socket_path=builtin_socket,
|
||||
)
|
||||
self._builtin_supervisor = builtin_supervisor
|
||||
self._register_capability_impls(builtin_supervisor)
|
||||
|
||||
if third_party_dirs:
|
||||
third_party_supervisor = self._instantiate_supervisor(
|
||||
PluginSupervisor,
|
||||
plugin_dirs=list(third_party_dirs),
|
||||
group_name="third_party",
|
||||
hook_spec_registry=self._hook_spec_registry,
|
||||
socket_path=third_party_socket,
|
||||
)
|
||||
self._third_party_supervisor = third_party_supervisor
|
||||
self._register_capability_impls(third_party_supervisor)
|
||||
|
||||
self._apply_blocked_plugin_reasons_to_supervisors()
|
||||
|
||||
async def _start_supervisors(
|
||||
self,
|
||||
builtin_dirs: Sequence[Path],
|
||||
third_party_dirs: Sequence[Path],
|
||||
) -> List["PluginSupervisor"]:
|
||||
"""按依赖顺序启动当前已创建的 Supervisor。
|
||||
|
||||
Args:
|
||||
builtin_dirs: 内置插件目录列表。
|
||||
third_party_dirs: 第三方插件目录列表。
|
||||
|
||||
Returns:
|
||||
List[PluginSupervisor]: 成功启动的 Supervisor 列表。
|
||||
"""
|
||||
|
||||
started_supervisors: List["PluginSupervisor"] = []
|
||||
supervisor_groups: Dict[str, Optional["PluginSupervisor"]] = {
|
||||
"builtin": self._builtin_supervisor,
|
||||
"third_party": self._third_party_supervisor,
|
||||
}
|
||||
start_order = self._build_group_start_order(builtin_dirs, third_party_dirs)
|
||||
|
||||
try:
|
||||
for group_name in start_order:
|
||||
supervisor = supervisor_groups.get(group_name)
|
||||
if supervisor is None:
|
||||
continue
|
||||
|
||||
external_plugin_versions = {
|
||||
plugin_id: plugin_version
|
||||
for started_supervisor in started_supervisors
|
||||
for plugin_id, plugin_version in started_supervisor.get_loaded_plugin_versions().items()
|
||||
}
|
||||
supervisor.set_external_available_plugins(external_plugin_versions)
|
||||
set_blocked_plugin_reasons = getattr(supervisor, "set_blocked_plugin_reasons", None)
|
||||
if callable(set_blocked_plugin_reasons):
|
||||
set_blocked_plugin_reasons(self._blocked_plugin_reasons)
|
||||
await supervisor.start()
|
||||
started_supervisors.append(supervisor)
|
||||
except Exception:
|
||||
await asyncio.gather(*(supervisor.stop() for supervisor in started_supervisors), return_exceptions=True)
|
||||
raise
|
||||
|
||||
return started_supervisors
|
||||
|
||||
async def _stop_supervisors(self) -> None:
|
||||
"""停止当前全部 Supervisor。"""
|
||||
|
||||
supervisors = self.supervisors
|
||||
if not supervisors:
|
||||
return
|
||||
|
||||
await asyncio.gather(*(supervisor.stop() for supervisor in supervisors), return_exceptions=True)
|
||||
self._builtin_supervisor = None
|
||||
self._third_party_supervisor = None
|
||||
|
||||
async def _restart_supervisors(self, reason: str) -> bool:
|
||||
"""重启当前全部 Supervisor。
|
||||
|
||||
Args:
|
||||
reason: 本次重启的原因。
|
||||
|
||||
Returns:
|
||||
bool: 是否重启成功。
|
||||
"""
|
||||
|
||||
builtin_dirs, third_party_dirs = self._resolve_runtime_plugin_dirs()
|
||||
if duplicate_plugin_ids := self._find_duplicate_plugin_ids(builtin_dirs + third_party_dirs):
|
||||
details = "; ".join(
|
||||
f"{plugin_id}: {', '.join(str(path) for path in paths)}"
|
||||
for plugin_id, paths in sorted(duplicate_plugin_ids.items())
|
||||
)
|
||||
logger.error(f"检测到重复插件 ID,拒绝执行 Supervisor 重启: {details}")
|
||||
return False
|
||||
|
||||
logger.info(f"开始重启插件运行时 Supervisor: {reason}")
|
||||
await self._stop_supervisors()
|
||||
self._build_supervisors(builtin_dirs, third_party_dirs)
|
||||
|
||||
try:
|
||||
await self._start_supervisors(builtin_dirs, third_party_dirs)
|
||||
except Exception as exc:
|
||||
logger.error(f"重启插件运行时 Supervisor 失败: {exc}", exc_info=True)
|
||||
await self._stop_supervisors()
|
||||
return False
|
||||
|
||||
self._refresh_plugin_config_watch_subscriptions()
|
||||
logger.info(f"插件运行时 Supervisor 已重启完成: {reason}")
|
||||
return True
|
||||
|
||||
# ─── 生命周期 ─────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
@@ -155,10 +418,7 @@ class PluginRuntimeManager(
|
||||
logger.info("插件运行时已在配置中禁用,跳过启动")
|
||||
return
|
||||
|
||||
from src.plugin_runtime.host.supervisor import PluginSupervisor
|
||||
|
||||
builtin_dirs = self._get_builtin_plugin_dirs()
|
||||
third_party_dirs = self._get_third_party_plugin_dirs()
|
||||
builtin_dirs, third_party_dirs = self._resolve_runtime_plugin_dirs()
|
||||
|
||||
if duplicate_plugin_ids := self._find_duplicate_plugin_ids(builtin_dirs + third_party_dirs):
|
||||
details = "; ".join(
|
||||
@@ -172,56 +432,19 @@ class PluginRuntimeManager(
|
||||
logger.info("未找到任何插件目录,跳过插件运行时启动")
|
||||
return
|
||||
|
||||
dependency_sync_state = await self._sync_plugin_dependencies(builtin_dirs + third_party_dirs)
|
||||
if dependency_sync_state.environment_changed:
|
||||
logger.info("插件依赖流水线已更新当前 Python 环境,启动时将直接加载最新环境")
|
||||
|
||||
self.ensure_builtin_hook_specs_registered()
|
||||
platform_io_manager = get_platform_io_manager()
|
||||
self._build_supervisors(builtin_dirs, third_party_dirs)
|
||||
|
||||
# 从配置读取自定义 IPC socket 路径(留空则自动生成)
|
||||
socket_path_base = _cfg.ipc_socket_path or None
|
||||
|
||||
# 当用户指定了自定义路径时,为两个 Supervisor 添加后缀以避免 UDS 冲突
|
||||
builtin_socket = f"{socket_path_base}-builtin" if socket_path_base else None
|
||||
third_party_socket = f"{socket_path_base}-third_party" if socket_path_base else None
|
||||
|
||||
# 创建两个 Supervisor,各自拥有独立的 socket / Runner 子进程
|
||||
if builtin_dirs:
|
||||
self._builtin_supervisor = PluginSupervisor(
|
||||
plugin_dirs=builtin_dirs,
|
||||
group_name="builtin",
|
||||
socket_path=builtin_socket,
|
||||
)
|
||||
self._register_capability_impls(self._builtin_supervisor)
|
||||
|
||||
if third_party_dirs:
|
||||
self._third_party_supervisor = PluginSupervisor(
|
||||
plugin_dirs=third_party_dirs,
|
||||
group_name="third_party",
|
||||
socket_path=third_party_socket,
|
||||
)
|
||||
self._register_capability_impls(self._third_party_supervisor)
|
||||
|
||||
started_supervisors: List[PluginSupervisor] = []
|
||||
started_supervisors: List["PluginSupervisor"] = []
|
||||
try:
|
||||
platform_io_manager.set_inbound_dispatcher(self._dispatch_platform_inbound)
|
||||
await platform_io_manager.ensure_send_pipeline_ready()
|
||||
|
||||
supervisor_groups: Dict[str, Optional[PluginSupervisor]] = {
|
||||
"builtin": self._builtin_supervisor,
|
||||
"third_party": self._third_party_supervisor,
|
||||
}
|
||||
start_order = self._build_group_start_order(builtin_dirs, third_party_dirs)
|
||||
|
||||
for group_name in start_order:
|
||||
supervisor = supervisor_groups.get(group_name)
|
||||
if supervisor is None:
|
||||
continue
|
||||
|
||||
external_plugin_versions = {
|
||||
plugin_id: plugin_version
|
||||
for started_supervisor in started_supervisors
|
||||
for plugin_id, plugin_version in started_supervisor.get_loaded_plugin_versions().items()
|
||||
}
|
||||
supervisor.set_external_available_plugins(external_plugin_versions)
|
||||
await supervisor.start()
|
||||
started_supervisors.append(supervisor)
|
||||
started_supervisors = await self._start_supervisors(builtin_dirs, third_party_dirs)
|
||||
|
||||
await self._start_plugin_file_watcher()
|
||||
config_manager.register_reload_callback(self._config_reload_callback)
|
||||
@@ -315,6 +538,7 @@ class PluginRuntimeManager(
|
||||
spec: 需要注册的 Hook 规格。
|
||||
"""
|
||||
|
||||
self.ensure_builtin_hook_specs_registered()
|
||||
self._hook_dispatcher.register_hook_spec(spec)
|
||||
|
||||
def register_hook_specs(self, specs: Sequence[HookSpec]) -> None:
|
||||
@@ -324,8 +548,41 @@ class PluginRuntimeManager(
|
||||
specs: 需要注册的 Hook 规格序列。
|
||||
"""
|
||||
|
||||
self.ensure_builtin_hook_specs_registered()
|
||||
self._hook_dispatcher.register_hook_specs(specs)
|
||||
|
||||
def unregister_hook_spec(self, hook_name: str) -> bool:
|
||||
"""注销指定命名 Hook 规格。
|
||||
|
||||
Args:
|
||||
hook_name: 目标 Hook 名称。
|
||||
|
||||
Returns:
|
||||
bool: 是否成功注销。
|
||||
"""
|
||||
|
||||
self.ensure_builtin_hook_specs_registered()
|
||||
return self._hook_dispatcher.unregister_hook_spec(hook_name)
|
||||
|
||||
def list_hook_specs(self) -> List[HookSpec]:
|
||||
"""返回当前全部命名 Hook 规格。
|
||||
|
||||
Returns:
|
||||
List[HookSpec]: 当前已注册的 Hook 规格列表。
|
||||
"""
|
||||
|
||||
self.ensure_builtin_hook_specs_registered()
|
||||
return self._hook_dispatcher.list_hook_specs()
|
||||
|
||||
def ensure_builtin_hook_specs_registered(self) -> None:
|
||||
"""确保内置 Hook 规格已经注册到共享中心表。"""
|
||||
|
||||
if self._builtin_hook_specs_registered:
|
||||
return
|
||||
|
||||
register_builtin_hook_specs(self._hook_spec_registry)
|
||||
self._builtin_hook_specs_registered = True
|
||||
|
||||
def _build_registered_dependency_map(self) -> Dict[str, Set[str]]:
|
||||
"""根据当前已注册插件构建全局依赖图。"""
|
||||
|
||||
@@ -364,9 +621,7 @@ class PluginRuntimeManager(
|
||||
"""构建当前已注册插件到所属 Supervisor 的映射。"""
|
||||
|
||||
return {
|
||||
plugin_id: supervisor
|
||||
for supervisor in self.supervisors
|
||||
for plugin_id in supervisor.get_loaded_plugin_ids()
|
||||
plugin_id: supervisor for supervisor in self.supervisors for plugin_id in supervisor.get_loaded_plugin_ids()
|
||||
}
|
||||
|
||||
def _build_external_available_plugins_for_supervisor(self, target_supervisor: "PluginSupervisor") -> Dict[str, str]:
|
||||
@@ -411,9 +666,7 @@ class PluginRuntimeManager(
|
||||
local_plugin_ids = set(supervisor.get_loaded_plugin_ids())
|
||||
local_dependency_map = {
|
||||
plugin_id: {
|
||||
dependency
|
||||
for dependency in dependency_map.get(plugin_id, set())
|
||||
if dependency in local_plugin_ids
|
||||
dependency for dependency in dependency_map.get(plugin_id, set()) if dependency in local_plugin_ids
|
||||
}
|
||||
for plugin_id in local_plugin_ids
|
||||
}
|
||||
@@ -440,13 +693,26 @@ class PluginRuntimeManager(
|
||||
"""
|
||||
|
||||
normalized_plugin_ids = [
|
||||
normalized_plugin_id
|
||||
for plugin_id in plugin_ids
|
||||
if (normalized_plugin_id := str(plugin_id or "").strip())
|
||||
normalized_plugin_id for plugin_id in plugin_ids if (normalized_plugin_id := str(plugin_id or "").strip())
|
||||
]
|
||||
if not normalized_plugin_ids:
|
||||
return True
|
||||
|
||||
blocked_plugin_ids = [plugin_id for plugin_id in normalized_plugin_ids if plugin_id in self._blocked_plugin_reasons]
|
||||
if blocked_plugin_ids:
|
||||
logger.warning(
|
||||
"以下插件当前被依赖流水线阻止加载,已拒绝重载请求: "
|
||||
+ ", ".join(
|
||||
f"{plugin_id} ({self._blocked_plugin_reasons[plugin_id]})"
|
||||
for plugin_id in sorted(blocked_plugin_ids)
|
||||
)
|
||||
)
|
||||
normalized_plugin_ids = [
|
||||
plugin_id for plugin_id in normalized_plugin_ids if plugin_id not in self._blocked_plugin_reasons
|
||||
]
|
||||
if not normalized_plugin_ids:
|
||||
return False
|
||||
|
||||
dependency_map = self._build_registered_dependency_map()
|
||||
supervisor_by_plugin = self._build_registered_supervisor_map()
|
||||
supervisor_roots: Dict["PluginSupervisor", List[str]] = {}
|
||||
@@ -518,9 +784,7 @@ class PluginRuntimeManager(
|
||||
return False
|
||||
|
||||
config_payload = (
|
||||
config_data
|
||||
if config_data is not None
|
||||
else self._load_plugin_config_for_supervisor(sv, plugin_id)
|
||||
config_data if config_data is not None else self._load_plugin_config_for_supervisor(sv, plugin_id)
|
||||
)
|
||||
return await sv.notify_plugin_config_updated(
|
||||
plugin_id=plugin_id,
|
||||
@@ -529,6 +793,91 @@ class PluginRuntimeManager(
|
||||
config_scope=config_scope,
|
||||
)
|
||||
|
||||
async def validate_plugin_config(self, plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None:
|
||||
"""请求运行时按插件自身配置模型校验配置。
|
||||
|
||||
Args:
|
||||
plugin_id: 目标插件 ID。
|
||||
config_data: 待校验的配置内容。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any] | None: 校验成功时返回规范化后的配置;若插件不存在、
|
||||
当前不可路由或运行时不可用,则返回 ``None`` 以便调用方回退到弱推断方案。
|
||||
|
||||
Raises:
|
||||
ValueError: 插件已加载,但配置校验失败时抛出。
|
||||
"""
|
||||
|
||||
if not self._started:
|
||||
return None
|
||||
|
||||
try:
|
||||
supervisor = self._get_supervisor_for_plugin(plugin_id)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(f"插件 {plugin_id} 配置校验路由失败,将回退到静态 Schema: {exc}")
|
||||
return None
|
||||
|
||||
if supervisor is None:
|
||||
supervisor = self._find_supervisor_by_plugin_directory(plugin_id)
|
||||
if supervisor is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await supervisor.validate_plugin_config(plugin_id, config_data)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(f"插件 {plugin_id} 运行时配置校验不可用,将回退到静态 Schema: {exc}")
|
||||
return None
|
||||
|
||||
async def inspect_plugin_config(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config_data: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
use_provided_config: bool = False,
|
||||
) -> InspectPluginConfigResultPayload | None:
|
||||
"""请求运行时解析插件配置元数据。
|
||||
|
||||
Args:
|
||||
plugin_id: 目标插件 ID。
|
||||
config_data: 可选的配置内容。
|
||||
use_provided_config: 是否优先使用传入的配置内容而不是磁盘配置。
|
||||
|
||||
Returns:
|
||||
InspectPluginConfigResultPayload | None: 解析成功时返回结构化结果;若插件
|
||||
当前不可路由或运行时不可用,则返回 ``None``。
|
||||
|
||||
Raises:
|
||||
ValueError: 插件存在,但运行时明确拒绝解析请求时抛出。
|
||||
"""
|
||||
|
||||
if not self._started:
|
||||
return None
|
||||
|
||||
try:
|
||||
supervisor = self._get_supervisor_for_plugin(plugin_id)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(f"插件 {plugin_id} 配置解析路由失败: {exc}")
|
||||
return None
|
||||
|
||||
if supervisor is None:
|
||||
supervisor = self._find_supervisor_by_plugin_directory(plugin_id)
|
||||
if supervisor is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await supervisor.inspect_plugin_config(
|
||||
plugin_id=plugin_id,
|
||||
config_data=config_data,
|
||||
use_provided_config=use_provided_config,
|
||||
)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(f"插件 {plugin_id} 配置解析不可用: {exc}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_config_reload_scopes(changed_scopes: Sequence[str]) -> tuple[str, ...]:
|
||||
"""规范化配置热重载范围列表。
|
||||
@@ -731,11 +1080,25 @@ class PluginRuntimeManager(
|
||||
return matches[0] if matches else None
|
||||
|
||||
async def load_plugin_globally(self, plugin_id: str, reason: str = "manual") -> bool:
|
||||
"""加载或重载单个插件,并为其补齐跨 Supervisor 外部依赖。"""
|
||||
"""加载或重载单个插件,并为其补齐跨 Supervisor 外部依赖。
|
||||
|
||||
Args:
|
||||
plugin_id: 目标插件 ID。
|
||||
reason: 加载或重载原因。
|
||||
|
||||
Returns:
|
||||
bool: 插件最终是否处于已加载状态。
|
||||
"""
|
||||
|
||||
normalized_plugin_id = str(plugin_id or "").strip()
|
||||
if not normalized_plugin_id:
|
||||
return False
|
||||
if normalized_plugin_id in self._blocked_plugin_reasons:
|
||||
logger.warning(
|
||||
f"插件 {normalized_plugin_id} 当前被依赖流水线阻止加载: "
|
||||
f"{self._blocked_plugin_reasons[normalized_plugin_id]}"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
registered_supervisor = self._get_supervisor_for_plugin(normalized_plugin_id)
|
||||
@@ -749,17 +1112,18 @@ class PluginRuntimeManager(
|
||||
if supervisor is None:
|
||||
return False
|
||||
|
||||
return await supervisor.reload_plugins(
|
||||
reloaded = await supervisor.reload_plugins(
|
||||
plugin_ids=[normalized_plugin_id],
|
||||
reason=reason,
|
||||
external_available_plugins=self._build_external_available_plugins_for_supervisor(supervisor),
|
||||
)
|
||||
return reloaded and normalized_plugin_id in supervisor.get_loaded_plugin_ids()
|
||||
|
||||
@classmethod
|
||||
def _find_duplicate_plugin_ids(cls, plugin_dirs: List[Path]) -> Dict[str, List[Path]]:
|
||||
"""扫描插件目录,找出被多个目录重复声明的插件 ID。"""
|
||||
plugin_locations: Dict[str, List[Path]] = {}
|
||||
validator = ManifestValidator()
|
||||
validator = ManifestValidator(validate_python_package_dependencies=False)
|
||||
for plugin_path, manifest in validator.iter_plugin_manifests(plugin_dirs):
|
||||
plugin_locations.setdefault(manifest.id, []).append(plugin_path)
|
||||
|
||||
@@ -869,7 +1233,9 @@ class PluginRuntimeManager(
|
||||
if self._plugin_dir_matches(cached_path, Path(plugin_dir)):
|
||||
return cached_path
|
||||
|
||||
for candidate_plugin_id, plugin_path in self._iter_discovered_plugin_paths(getattr(supervisor, "_plugin_dirs", [])):
|
||||
for candidate_plugin_id, plugin_path in self._iter_discovered_plugin_paths(
|
||||
getattr(supervisor, "_plugin_dirs", [])
|
||||
):
|
||||
if candidate_plugin_id != plugin_id:
|
||||
continue
|
||||
self._plugin_path_cache[plugin_id] = plugin_path
|
||||
@@ -878,15 +1244,16 @@ class PluginRuntimeManager(
|
||||
return None
|
||||
|
||||
def _refresh_plugin_config_watch_subscriptions(self) -> None:
|
||||
"""按当前已注册插件集合刷新 config.toml 的单插件订阅。
|
||||
"""按当前可识别插件集合刷新 config.toml 的单插件订阅。
|
||||
|
||||
当插件热重载后,插件集合或目录位置可能发生变化,因此需要重新对齐
|
||||
watcher 的订阅,确保每个插件配置变更只触发对应 plugin_id。
|
||||
这里不仅覆盖当前已注册插件,也覆盖已存在但暂未激活的合法插件。
|
||||
"""
|
||||
if self._plugin_file_watcher is None:
|
||||
return
|
||||
|
||||
desired_plugin_paths = dict(self._iter_registered_plugin_paths())
|
||||
desired_plugin_paths = dict(self._iter_watchable_plugin_paths())
|
||||
self._plugin_path_cache = desired_plugin_paths.copy()
|
||||
desired_config_paths = {
|
||||
plugin_id: self._resolve_plugin_config_path(plugin_id, plugin_path)
|
||||
@@ -909,9 +1276,7 @@ class PluginRuntimeManager(
|
||||
)
|
||||
self._plugin_config_watcher_subscriptions[plugin_id] = (config_path, subscription_id)
|
||||
|
||||
def _build_plugin_config_change_callback(
|
||||
self, plugin_id: str
|
||||
) -> Callable[[Sequence[FileChange]], Awaitable[None]]:
|
||||
def _build_plugin_config_change_callback(self, plugin_id: str) -> Callable[[Sequence[FileChange]], Awaitable[None]]:
|
||||
"""为指定插件生成配置文件变更回调。"""
|
||||
|
||||
async def _callback(changes: Sequence[FileChange]) -> None:
|
||||
@@ -931,6 +1296,18 @@ class PluginRuntimeManager(
|
||||
if plugin_path := self._get_plugin_path_for_supervisor(supervisor, plugin_id):
|
||||
yield plugin_id, plugin_path
|
||||
|
||||
def _iter_watchable_plugin_paths(self) -> Iterable[Tuple[str, Path]]:
|
||||
"""迭代应被配置监听器追踪的插件目录。
|
||||
|
||||
Returns:
|
||||
Iterable[Tuple[str, Path]]: ``(plugin_id, plugin_path)`` 迭代器。
|
||||
"""
|
||||
|
||||
watchable_plugin_paths = dict(self._iter_discovered_plugin_paths(self._iter_plugin_dirs()))
|
||||
for plugin_id, plugin_path in self._iter_registered_plugin_paths():
|
||||
watchable_plugin_paths.setdefault(plugin_id, plugin_path)
|
||||
yield from watchable_plugin_paths.items()
|
||||
|
||||
def _get_plugin_config_path_for_supervisor(self, supervisor: Any, plugin_id: str) -> Optional[Path]:
|
||||
"""从指定 Supervisor 的插件目录中定位某个插件的 config.toml。"""
|
||||
plugin_path = self._get_plugin_path_for_supervisor(supervisor, plugin_id)
|
||||
@@ -958,18 +1335,43 @@ class PluginRuntimeManager(
|
||||
return
|
||||
|
||||
if supervisor is None:
|
||||
supervisor = self._find_supervisor_by_plugin_directory(plugin_id)
|
||||
if supervisor is None:
|
||||
return
|
||||
|
||||
plugin_is_loaded = plugin_id in getattr(supervisor, "_registered_plugins", {})
|
||||
|
||||
try:
|
||||
snapshot = await supervisor.inspect_plugin_config(plugin_id)
|
||||
except Exception as exc:
|
||||
logger.warning(f"插件 {plugin_id} 配置文件变更解析失败: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
config_payload = self._load_plugin_config_for_supervisor(supervisor, plugin_id)
|
||||
delivered = await supervisor.notify_plugin_config_updated(
|
||||
plugin_id=plugin_id,
|
||||
config_data=config_payload,
|
||||
config_version="",
|
||||
config_scope="self",
|
||||
)
|
||||
if not delivered:
|
||||
logger.warning(f"插件 {plugin_id} 配置文件变更后通知失败")
|
||||
if plugin_is_loaded and snapshot.enabled:
|
||||
delivered = await supervisor.notify_plugin_config_updated(
|
||||
plugin_id=plugin_id,
|
||||
config_data=dict(snapshot.normalized_config),
|
||||
config_version="",
|
||||
config_scope="self",
|
||||
)
|
||||
if not delivered:
|
||||
logger.warning(f"插件 {plugin_id} 配置文件变更后通知失败")
|
||||
return
|
||||
|
||||
if plugin_is_loaded and not snapshot.enabled:
|
||||
reloaded = await self.reload_plugins_globally([plugin_id], reason="config_disabled")
|
||||
if not reloaded:
|
||||
logger.warning(f"插件 {plugin_id} 禁用配置已写入,但运行时卸载失败")
|
||||
return
|
||||
|
||||
if not snapshot.enabled:
|
||||
logger.info(f"插件 {plugin_id} 当前处于禁用状态,跳过自动加载")
|
||||
return
|
||||
|
||||
loaded = await self.load_plugin_globally(plugin_id, reason="config_enabled")
|
||||
if not loaded:
|
||||
logger.warning(f"插件 {plugin_id} 配置文件变更后自动加载失败")
|
||||
except Exception as exc:
|
||||
logger.warning(f"插件 {plugin_id} 配置文件变更处理失败: {exc}")
|
||||
|
||||
@@ -983,7 +1385,8 @@ class PluginRuntimeManager(
|
||||
if not self._started or not changes:
|
||||
return
|
||||
|
||||
if duplicate_plugin_ids := self._find_duplicate_plugin_ids(list(self._iter_plugin_dirs())):
|
||||
plugin_dirs = list(self._iter_plugin_dirs())
|
||||
if duplicate_plugin_ids := self._find_duplicate_plugin_ids(plugin_dirs):
|
||||
details = "; ".join(
|
||||
f"{plugin_id}: {', '.join(str(path) for path in paths)}"
|
||||
for plugin_id, paths in sorted(duplicate_plugin_ids.items())
|
||||
@@ -991,21 +1394,24 @@ class PluginRuntimeManager(
|
||||
logger.error(f"检测到重复插件 ID,跳过本次插件热重载: {details}")
|
||||
return
|
||||
|
||||
changed_plugin_ids: List[str] = []
|
||||
changed_paths = [change.path.resolve() for change in changes]
|
||||
relevant_source_changes = [
|
||||
change.path.resolve()
|
||||
for change in changes
|
||||
if change.path.name in {"plugin.py", "_manifest.json"} or change.path.suffix == ".py"
|
||||
]
|
||||
if not relevant_source_changes:
|
||||
return
|
||||
|
||||
for supervisor in self.supervisors:
|
||||
for path in changed_paths:
|
||||
plugin_id = self._match_plugin_id_for_supervisor(supervisor, path)
|
||||
if plugin_id is None:
|
||||
continue
|
||||
if path.name in {"plugin.py", "_manifest.json"} or path.suffix == ".py":
|
||||
if plugin_id not in changed_plugin_ids:
|
||||
changed_plugin_ids.append(plugin_id)
|
||||
dependency_sync_state = await self._sync_plugin_dependencies(plugin_dirs)
|
||||
restart_reason = "file_watcher"
|
||||
if dependency_sync_state.environment_changed:
|
||||
restart_reason = "file_watcher_dependency_install"
|
||||
elif dependency_sync_state.blocked_changed_plugin_ids:
|
||||
restart_reason = "file_watcher_blocklist_changed"
|
||||
|
||||
if changed_plugin_ids:
|
||||
await self.reload_plugins_globally(changed_plugin_ids, reason="file_watcher")
|
||||
self._refresh_plugin_config_watch_subscriptions()
|
||||
restarted = await self._restart_supervisors(restart_reason)
|
||||
if not restarted:
|
||||
logger.warning(f"插件源码变更后重启 Supervisor 失败: {restart_reason}")
|
||||
|
||||
@staticmethod
|
||||
def _plugin_dir_matches(path: Path, plugin_dir: Path) -> bool:
|
||||
@@ -1023,7 +1429,10 @@ class PluginRuntimeManager(
|
||||
return plugin_id
|
||||
|
||||
for plugin_id, plugin_path in self._plugin_path_cache.items():
|
||||
if not any(self._plugin_dir_matches(plugin_path, Path(plugin_dir)) for plugin_dir in getattr(supervisor, "_plugin_dirs", [])):
|
||||
if not any(
|
||||
self._plugin_dir_matches(plugin_path, Path(plugin_dir))
|
||||
for plugin_dir in getattr(supervisor, "_plugin_dirs", [])
|
||||
):
|
||||
continue
|
||||
if resolved_path == plugin_path or resolved_path.is_relative_to(plugin_path):
|
||||
return plugin_id
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""RPC Envelope 消息模型
|
||||
"""RPC Envelope 消息模型。
|
||||
|
||||
定义 Host 与 Runner 之间所有 RPC 消息的统一信封格式。
|
||||
使用 Pydantic 进行 schema 定义与校验。
|
||||
使用 Pydantic 进行 Schema 定义与校验。
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
@@ -39,12 +39,23 @@ class ConfigReloadScope(str, Enum):
|
||||
|
||||
# ====== 请求 ID 生成器 ======
|
||||
class RequestIdGenerator:
|
||||
"""单调递增 int64 请求 ID 生成器"""
|
||||
"""单调递增 int64 请求 ID 生成器。"""
|
||||
|
||||
def __init__(self, start: int = 1) -> None:
|
||||
"""初始化请求 ID 生成器。
|
||||
|
||||
Args:
|
||||
start: 起始请求 ID。
|
||||
"""
|
||||
self._counter = start
|
||||
|
||||
async def next(self) -> int:
|
||||
"""返回下一个请求 ID。
|
||||
|
||||
Returns:
|
||||
int: 下一个可用的请求 ID。
|
||||
"""
|
||||
|
||||
current = self._counter
|
||||
self._counter += 1
|
||||
return current
|
||||
@@ -52,7 +63,7 @@ class RequestIdGenerator:
|
||||
|
||||
# ====== Envelope 模型 ======
|
||||
class Envelope(BaseModel):
|
||||
"""RPC 统一消息封装
|
||||
"""RPC 统一消息封装。
|
||||
|
||||
所有 Host <-> Runner 消息均封装为此格式。
|
||||
序列化流程:Envelope -> .model_dump() -> MsgPack encode
|
||||
@@ -79,18 +90,44 @@ class Envelope(BaseModel):
|
||||
"""错误信息 (仅 response)"""
|
||||
|
||||
def is_request(self) -> bool:
|
||||
"""判断当前信封是否为请求消息。
|
||||
|
||||
Returns:
|
||||
bool: 当前消息类型是否为 ``REQUEST``。
|
||||
"""
|
||||
|
||||
return self.message_type == MessageType.REQUEST
|
||||
|
||||
def is_response(self) -> bool:
|
||||
"""判断当前信封是否为响应消息。
|
||||
|
||||
Returns:
|
||||
bool: 当前消息类型是否为 ``RESPONSE``。
|
||||
"""
|
||||
|
||||
return self.message_type == MessageType.RESPONSE
|
||||
|
||||
def is_broadcast(self) -> bool:
|
||||
"""判断当前信封是否为广播消息。
|
||||
|
||||
Returns:
|
||||
bool: 当前消息类型是否为 ``BROADCAST``。
|
||||
"""
|
||||
|
||||
return self.message_type == MessageType.BROADCAST
|
||||
|
||||
def make_response(
|
||||
self, payload: Optional[Dict[str, Any]] = None, error: Optional[Dict[str, Any]] = None
|
||||
) -> "Envelope":
|
||||
"""基于当前请求创建对应的响应信封"""
|
||||
"""基于当前请求创建对应的响应信封。
|
||||
|
||||
Args:
|
||||
payload: 响应业务载荷。
|
||||
error: 响应错误信息。
|
||||
|
||||
Returns:
|
||||
Envelope: 对应的响应信封。
|
||||
"""
|
||||
return Envelope(
|
||||
protocol_version=self.protocol_version,
|
||||
request_id=self.request_id,
|
||||
@@ -102,7 +139,16 @@ class Envelope(BaseModel):
|
||||
)
|
||||
|
||||
def make_error_response(self, code: str, message: str = "", details: Optional[Dict[str, Any]] = None) -> "Envelope":
|
||||
"""基于当前请求创建错误响应"""
|
||||
"""基于当前请求创建错误响应。
|
||||
|
||||
Args:
|
||||
code: 错误码。
|
||||
message: 错误描述。
|
||||
details: 详细错误信息。
|
||||
|
||||
Returns:
|
||||
Envelope: 错误响应信封。
|
||||
"""
|
||||
return self.make_response(
|
||||
error={
|
||||
"code": code,
|
||||
@@ -141,9 +187,7 @@ class ComponentDeclaration(BaseModel):
|
||||
|
||||
name: str = Field(description="组件名称")
|
||||
"""组件名称"""
|
||||
component_type: str = Field(
|
||||
description="组件类型:action/command/tool/event_handler/hook_handler/message_gateway"
|
||||
)
|
||||
component_type: str = Field(description="组件类型:action/command/tool/event_handler/hook_handler/message_gateway")
|
||||
"""组件类型:`action`/`command`/`tool`/`event_handler`/`hook_handler`/`message_gateway`"""
|
||||
plugin_id: str = Field(description="所属插件 ID")
|
||||
"""所属插件 ID"""
|
||||
@@ -170,6 +214,10 @@ class RegisterPluginPayload(BaseModel):
|
||||
"""插件级依赖插件 ID 列表"""
|
||||
config_reload_subscriptions: List[str] = Field(default_factory=list, description="订阅的全局配置热重载范围")
|
||||
"""订阅的全局配置热重载范围"""
|
||||
default_config: Dict[str, Any] = Field(default_factory=dict, description="插件默认配置")
|
||||
"""插件默认配置"""
|
||||
config_schema: Dict[str, Any] = Field(default_factory=dict, description="插件配置 Schema")
|
||||
"""插件配置 Schema"""
|
||||
|
||||
|
||||
class BootstrapPluginPayload(BaseModel):
|
||||
@@ -240,6 +288,8 @@ class RunnerReadyPayload(BaseModel):
|
||||
"""已完成初始化的插件列表"""
|
||||
failed_plugins: List[str] = Field(default_factory=list, description="初始化失败的插件列表")
|
||||
"""初始化失败的插件列表"""
|
||||
inactive_plugins: List[str] = Field(default_factory=list, description="当前因禁用或依赖不可用而未激活的插件列表")
|
||||
"""当前因禁用或依赖不可用而未激活的插件列表"""
|
||||
|
||||
|
||||
# ====== 配置更新 ======
|
||||
@@ -256,6 +306,50 @@ class ConfigUpdatedPayload(BaseModel):
|
||||
"""配置内容"""
|
||||
|
||||
|
||||
class ValidatePluginConfigPayload(BaseModel):
|
||||
"""plugin.validate_config 请求 payload。"""
|
||||
|
||||
config_data: Dict[str, Any] = Field(default_factory=dict, description="待校验的配置内容")
|
||||
"""待校验的配置内容"""
|
||||
|
||||
|
||||
class InspectPluginConfigPayload(BaseModel):
|
||||
"""plugin.inspect_config 请求 payload。"""
|
||||
|
||||
config_data: Dict[str, Any] = Field(default_factory=dict, description="可选的配置内容")
|
||||
"""可选的配置内容"""
|
||||
use_provided_config: bool = Field(default=False, description="是否优先使用请求中携带的配置内容")
|
||||
"""是否优先使用请求中携带的配置内容"""
|
||||
|
||||
|
||||
class InspectPluginConfigResultPayload(BaseModel):
|
||||
"""plugin.inspect_config 响应 payload。"""
|
||||
|
||||
success: bool = Field(description="是否解析成功")
|
||||
"""是否解析成功"""
|
||||
default_config: Dict[str, Any] = Field(default_factory=dict, description="插件默认配置")
|
||||
"""插件默认配置"""
|
||||
config_schema: Dict[str, Any] = Field(default_factory=dict, description="插件配置 Schema")
|
||||
"""插件配置 Schema"""
|
||||
normalized_config: Dict[str, Any] = Field(default_factory=dict, description="归一化后的配置内容")
|
||||
"""归一化后的配置内容"""
|
||||
changed: bool = Field(default=False, description="是否在归一化过程中自动补齐或修正了配置")
|
||||
"""是否在归一化过程中自动补齐或修正了配置"""
|
||||
enabled: bool = Field(default=True, description="插件在当前配置下是否应被视为启用")
|
||||
"""插件在当前配置下是否应被视为启用"""
|
||||
|
||||
|
||||
class ValidatePluginConfigResultPayload(BaseModel):
|
||||
"""plugin.validate_config 响应 payload。"""
|
||||
|
||||
success: bool = Field(description="是否校验成功")
|
||||
"""是否校验成功"""
|
||||
normalized_config: Dict[str, Any] = Field(default_factory=dict, description="校验后的规范化配置")
|
||||
"""校验后的规范化配置"""
|
||||
changed: bool = Field(default=False, description="是否在校验过程中自动补齐或归一化")
|
||||
"""是否在校验过程中自动补齐或归一化"""
|
||||
|
||||
|
||||
# ====== 关停 ======
|
||||
class ShutdownPayload(BaseModel):
|
||||
"""plugin.shutdown / plugin.prepare_shutdown payload"""
|
||||
@@ -314,6 +408,8 @@ class ReloadPluginResultPayload(BaseModel):
|
||||
"""成功完成重载的插件列表"""
|
||||
unloaded_plugins: List[str] = Field(default_factory=list, description="本次已卸载的插件列表")
|
||||
"""本次已卸载的插件列表"""
|
||||
inactive_plugins: List[str] = Field(default_factory=list, description="本次处于未激活状态的插件列表")
|
||||
"""本次处于未激活状态的插件列表"""
|
||||
failed_plugins: Dict[str, str] = Field(default_factory=dict, description="重载失败的插件及原因")
|
||||
"""重载失败的插件及原因"""
|
||||
|
||||
@@ -329,6 +425,8 @@ class ReloadPluginsResultPayload(BaseModel):
|
||||
"""成功完成重载的插件列表"""
|
||||
unloaded_plugins: List[str] = Field(default_factory=list, description="本次已卸载的插件列表")
|
||||
"""本次已卸载的插件列表"""
|
||||
inactive_plugins: List[str] = Field(default_factory=list, description="本次处于未激活状态的插件列表")
|
||||
"""本次处于未激活状态的插件列表"""
|
||||
failed_plugins: Dict[str, str] = Field(default_factory=dict, description="重载失败的插件及原因")
|
||||
"""重载失败的插件及原因"""
|
||||
|
||||
|
||||
@@ -609,6 +609,7 @@ class ManifestValidator:
|
||||
host_version: str = "",
|
||||
sdk_version: str = "",
|
||||
project_root: Optional[Path] = None,
|
||||
validate_python_package_dependencies: bool = True,
|
||||
) -> None:
|
||||
"""初始化 Manifest 校验器。
|
||||
|
||||
@@ -616,10 +617,12 @@ class ManifestValidator:
|
||||
host_version: 当前 Host 版本号;留空时自动从主程序 ``pyproject.toml`` 读取。
|
||||
sdk_version: 当前 SDK 版本号;留空时自动从运行环境中探测。
|
||||
project_root: 项目根目录;留空时自动推断。
|
||||
validate_python_package_dependencies: 是否校验 Python 包依赖与当前环境的关系。
|
||||
"""
|
||||
self._project_root: Path = project_root or self._resolve_project_root()
|
||||
self._host_version: str = host_version or self._detect_default_host_version(self._project_root)
|
||||
self._sdk_version: str = sdk_version or self._detect_default_sdk_version(self._project_root)
|
||||
self._validate_python_package_dependencies: bool = validate_python_package_dependencies
|
||||
self.errors: List[str] = []
|
||||
self.warnings: List[str] = []
|
||||
|
||||
@@ -823,9 +826,10 @@ class ManifestValidator:
|
||||
if not sdk_ok:
|
||||
self.errors.append(f"SDK 版本不兼容: {sdk_message} (当前 SDK: {self._sdk_version})")
|
||||
|
||||
self._validate_python_package_dependencies(manifest)
|
||||
if self._validate_python_package_dependencies:
|
||||
self._validate_python_package_dependencies_against_runtime(manifest)
|
||||
|
||||
def _validate_python_package_dependencies(self, manifest: PluginManifest) -> None:
|
||||
def _validate_python_package_dependencies_against_runtime(self, manifest: PluginManifest) -> None:
|
||||
"""校验 Python 包依赖与主程序运行环境是否冲突。
|
||||
|
||||
Args:
|
||||
@@ -865,6 +869,68 @@ class ManifestValidator:
|
||||
f"主程序依赖约束为 {host_specifier or '任意版本'}"
|
||||
)
|
||||
|
||||
def load_host_dependency_requirements(self) -> Dict[str, Requirement]:
|
||||
"""读取主程序在 ``pyproject.toml`` 中声明的依赖约束。
|
||||
|
||||
Returns:
|
||||
Dict[str, Requirement]: 以规范化包名为键的依赖约束映射。
|
||||
"""
|
||||
|
||||
return self._load_host_dependency_requirements(self._project_root)
|
||||
|
||||
def get_installed_package_version(self, package_name: str) -> Optional[str]:
|
||||
"""查询当前运行环境中指定包的安装版本。
|
||||
|
||||
Args:
|
||||
package_name: 需要查询的包名。
|
||||
|
||||
Returns:
|
||||
Optional[str]: 已安装版本号;未安装时返回 ``None``。
|
||||
"""
|
||||
|
||||
return self._get_installed_package_version(package_name)
|
||||
|
||||
@staticmethod
|
||||
def build_specifier_set(version_spec: str) -> Optional[SpecifierSet]:
|
||||
"""将版本约束文本转换为 ``SpecifierSet``。
|
||||
|
||||
Args:
|
||||
version_spec: 原始版本约束文本。
|
||||
|
||||
Returns:
|
||||
Optional[SpecifierSet]: 转换成功时返回约束对象,否则返回 ``None``。
|
||||
"""
|
||||
|
||||
return ManifestValidator._build_specifier_set(version_spec)
|
||||
|
||||
@staticmethod
|
||||
def version_matches_specifier(version: str, version_spec: str) -> bool:
|
||||
"""判断版本号是否满足给定约束。
|
||||
|
||||
Args:
|
||||
version: 待判断的版本号。
|
||||
version_spec: 版本约束表达式。
|
||||
|
||||
Returns:
|
||||
bool: 是否满足约束。
|
||||
"""
|
||||
|
||||
return ManifestValidator._version_matches_specifier(version, version_spec)
|
||||
|
||||
@classmethod
|
||||
def requirements_may_overlap(cls, left: SpecifierSet, right: SpecifierSet) -> bool:
|
||||
"""判断两个版本约束是否可能存在交集。
|
||||
|
||||
Args:
|
||||
left: 左侧版本约束。
|
||||
right: 右侧版本约束。
|
||||
|
||||
Returns:
|
||||
bool: 若两者可能同时满足则返回 ``True``。
|
||||
"""
|
||||
|
||||
return cls._requirements_may_overlap(left, right)
|
||||
|
||||
def _log_errors(self) -> None:
|
||||
"""输出当前累计的 Manifest 校验错误。"""
|
||||
for error_message in self.errors:
|
||||
|
||||
@@ -75,6 +75,35 @@ class PluginLoader:
|
||||
self._failed_plugins: Dict[str, str] = {}
|
||||
self._manifest_validator = ManifestValidator(host_version=host_version)
|
||||
self._compat_hook_installed = False
|
||||
self._blocked_plugin_reasons: Dict[str, str] = {}
|
||||
|
||||
def set_blocked_plugin_reasons(self, blocked_plugin_reasons: Optional[Dict[str, str]] = None) -> None:
|
||||
"""更新当前加载器持有的拒绝加载插件列表。
|
||||
|
||||
Args:
|
||||
blocked_plugin_reasons: 需要拒绝加载的插件及原因映射。
|
||||
"""
|
||||
|
||||
self._blocked_plugin_reasons = {
|
||||
str(plugin_id or "").strip(): str(reason or "").strip()
|
||||
for plugin_id, reason in (blocked_plugin_reasons or {}).items()
|
||||
if str(plugin_id or "").strip() and str(reason or "").strip()
|
||||
}
|
||||
|
||||
def get_blocked_plugin_reason(self, plugin_id: str) -> Optional[str]:
|
||||
"""返回指定插件当前的拒绝加载原因。
|
||||
|
||||
Args:
|
||||
plugin_id: 目标插件 ID。
|
||||
|
||||
Returns:
|
||||
Optional[str]: 若插件被阻止加载则返回原因,否则返回 ``None``。
|
||||
"""
|
||||
|
||||
normalized_plugin_id = str(plugin_id or "").strip()
|
||||
if not normalized_plugin_id:
|
||||
return None
|
||||
return self._blocked_plugin_reasons.get(normalized_plugin_id)
|
||||
|
||||
def discover_and_load(
|
||||
self,
|
||||
@@ -156,6 +185,11 @@ class PluginLoader:
|
||||
return None
|
||||
|
||||
plugin_id = manifest.id
|
||||
if blocked_reason := self.get_blocked_plugin_reason(plugin_id):
|
||||
self._failed_plugins[plugin_id] = blocked_reason
|
||||
logger.warning(f"插件 {plugin_id} 已被 Host 依赖流水线阻止加载: {blocked_reason}")
|
||||
return None
|
||||
|
||||
return plugin_id, (plugin_dir, manifest, plugin_path)
|
||||
|
||||
def _record_duplicate_candidates(self, duplicate_candidates: Dict[str, List[Path]]) -> None:
|
||||
|
||||
@@ -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, Optional, Protocol, Set, cast
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
@@ -23,8 +25,11 @@ import sys
|
||||
import time
|
||||
import tomllib
|
||||
|
||||
import tomlkit
|
||||
|
||||
from src.common.logger import get_console_handler, get_logger, initialize_logging
|
||||
from src.plugin_runtime import (
|
||||
ENV_BLOCKED_PLUGIN_REASONS,
|
||||
ENV_EXTERNAL_PLUGIN_IDS,
|
||||
ENV_HOST_VERSION,
|
||||
ENV_IPC_ADDRESS,
|
||||
@@ -37,6 +42,8 @@ from src.plugin_runtime.protocol.envelope import (
|
||||
ConfigUpdatedPayload,
|
||||
Envelope,
|
||||
HealthPayload,
|
||||
InspectPluginConfigPayload,
|
||||
InspectPluginConfigResultPayload,
|
||||
InvokePayload,
|
||||
InvokeResultPayload,
|
||||
RegisterPluginPayload,
|
||||
@@ -46,6 +53,8 @@ from src.plugin_runtime.protocol.envelope import (
|
||||
ReloadPluginsResultPayload,
|
||||
RunnerReadyPayload,
|
||||
UnregisterPluginPayload,
|
||||
ValidatePluginConfigPayload,
|
||||
ValidatePluginConfigResultPayload,
|
||||
)
|
||||
from src.plugin_runtime.protocol.errors import ErrorCode
|
||||
from src.plugin_runtime.runner.log_handler import RunnerIPCLogHandler
|
||||
@@ -79,6 +88,72 @@ class _ContextAwarePlugin(Protocol):
|
||||
"""
|
||||
|
||||
|
||||
class _ConfigAwarePlugin(Protocol):
|
||||
"""支持声明式插件配置能力的插件协议。"""
|
||||
|
||||
def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]:
|
||||
"""对插件配置进行归一化与补齐。
|
||||
|
||||
Args:
|
||||
config_data: 原始配置数据。
|
||||
|
||||
Returns:
|
||||
Tuple[Dict[str, Any], bool]: 归一化后的配置,以及是否发生自动变更。
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
def set_plugin_config(self, config: Dict[str, Any]) -> None:
|
||||
"""注入插件当前配置。
|
||||
|
||||
Args:
|
||||
config: 当前最新插件配置。
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
def get_default_config(self) -> Dict[str, Any]:
|
||||
"""返回插件默认配置。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 默认配置字典。
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
def get_webui_config_schema(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str = "",
|
||||
plugin_name: str = "",
|
||||
plugin_version: str = "",
|
||||
plugin_description: str = "",
|
||||
plugin_author: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""返回插件配置 Schema。
|
||||
|
||||
Args:
|
||||
plugin_id: 插件 ID。
|
||||
plugin_name: 插件名称。
|
||||
plugin_version: 插件版本。
|
||||
plugin_description: 插件描述。
|
||||
plugin_author: 插件作者。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: WebUI 配置 Schema。
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
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,
|
||||
@@ -122,6 +197,7 @@ class PluginRunner:
|
||||
session_token: str,
|
||||
plugin_dirs: List[str],
|
||||
external_available_plugins: Optional[Dict[str, str]] = None,
|
||||
blocked_plugin_reasons: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
"""初始化 Runner。
|
||||
|
||||
@@ -130,6 +206,7 @@ class PluginRunner:
|
||||
session_token: 握手用会话令牌。
|
||||
plugin_dirs: 当前 Runner 负责扫描的插件目录列表。
|
||||
external_available_plugins: 视为已满足的外部依赖插件版本映射。
|
||||
blocked_plugin_reasons: 需要拒绝加载的插件及原因映射。
|
||||
"""
|
||||
self._host_address: str = host_address
|
||||
self._session_token: str = session_token
|
||||
@@ -139,9 +216,15 @@ class PluginRunner:
|
||||
for plugin_id, plugin_version in (external_available_plugins or {}).items()
|
||||
if str(plugin_id or "").strip() and str(plugin_version or "").strip()
|
||||
}
|
||||
self._blocked_plugin_reasons: Dict[str, str] = {
|
||||
str(plugin_id or "").strip(): str(reason or "").strip()
|
||||
for plugin_id, reason in (blocked_plugin_reasons or {}).items()
|
||||
if str(plugin_id or "").strip() and str(reason or "").strip()
|
||||
}
|
||||
|
||||
self._rpc_client: RPCClient = RPCClient(host_address, session_token)
|
||||
self._loader: PluginLoader = PluginLoader(host_version=os.getenv(ENV_HOST_VERSION, ""))
|
||||
self._loader.set_blocked_plugin_reasons(self._blocked_plugin_reasons)
|
||||
self._start_time: float = time.monotonic()
|
||||
self._shutting_down: bool = False
|
||||
self._reload_lock: asyncio.Lock = asyncio.Lock()
|
||||
@@ -174,13 +257,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):
|
||||
@@ -271,14 +384,11 @@ class PluginRunner:
|
||||
始终绑定为当前插件实例,避免伪造其他插件身份申请能力。
|
||||
"""
|
||||
if plugin_id and plugin_id != bound_plugin_id:
|
||||
logger.warning(
|
||||
f"插件 {bound_plugin_id} 尝试以 {plugin_id} 身份发起 RPC,已强制绑定回自身身份"
|
||||
)
|
||||
logger.warning(f"插件 {bound_plugin_id} 尝试以 {plugin_id} 身份发起 RPC,已强制绑定回自身身份")
|
||||
normalized_method = str(method or "").strip()
|
||||
if normalized_method not in _PLUGIN_ALLOWED_RAW_HOST_METHODS:
|
||||
raise PermissionError(
|
||||
f"插件 {bound_plugin_id} 不允许直接调用 Host 原始 RPC 方法: "
|
||||
f"{normalized_method or '<empty>'}"
|
||||
f"插件 {bound_plugin_id} 不允许直接调用 Host 原始 RPC 方法: {normalized_method or '<empty>'}"
|
||||
)
|
||||
resp = await rpc_client.send_request(
|
||||
method=normalized_method,
|
||||
@@ -293,17 +403,101 @@ 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:
|
||||
"""在 Runner 侧为插件实例注入当前插件配置。"""
|
||||
instance = meta.instance
|
||||
if not hasattr(instance, "set_plugin_config"):
|
||||
return
|
||||
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
|
||||
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"
|
||||
default_config = self._get_plugin_default_config(instance)
|
||||
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)
|
||||
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,
|
||||
instance: object,
|
||||
config_data: Optional[Dict[str, Any]],
|
||||
*,
|
||||
suppress_errors: bool = True,
|
||||
) -> Tuple[Dict[str, Any], bool]:
|
||||
"""对插件配置做统一归一化处理。
|
||||
|
||||
Args:
|
||||
instance: 插件实例。
|
||||
config_data: 原始配置数据。
|
||||
suppress_errors: 是否在归一化失败时吞掉异常并回退原始配置。
|
||||
|
||||
Returns:
|
||||
Tuple[Dict[str, Any], bool]: 归一化后的配置,以及是否需要回写文件。
|
||||
"""
|
||||
|
||||
normalized_config = dict(config_data or {})
|
||||
if not hasattr(instance, "normalize_plugin_config"):
|
||||
return normalized_config, False
|
||||
|
||||
plugin_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir, meta.plugin_id)
|
||||
try:
|
||||
instance.set_plugin_config(plugin_config)
|
||||
return cast(_ConfigAwarePlugin, instance).normalize_plugin_config(normalized_config)
|
||||
except Exception as exc:
|
||||
logger.warning(f"插件 {meta.plugin_id} 配置注入失败: {exc}")
|
||||
if not suppress_errors:
|
||||
raise
|
||||
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``。
|
||||
|
||||
Args:
|
||||
plugin_dir: 插件目录。
|
||||
config_data: 需要写回的配置字典。
|
||||
"""
|
||||
|
||||
config_path = Path(plugin_dir) / "config.toml"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with config_path.open("w", encoding="utf-8") as handle:
|
||||
handle.write(tomlkit.dumps(config_data))
|
||||
|
||||
@staticmethod
|
||||
def _load_plugin_config(plugin_dir: str, plugin_id: str = "") -> Dict[str, Any]:
|
||||
@@ -322,6 +516,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)
|
||||
@@ -335,6 +622,8 @@ 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)
|
||||
|
||||
@@ -452,6 +741,8 @@ class PluginRunner:
|
||||
capabilities_required=meta.capabilities_required,
|
||||
dependencies=meta.dependencies,
|
||||
config_reload_subscriptions=config_reload_subscriptions,
|
||||
default_config=self._get_plugin_default_config(instance),
|
||||
config_schema=self._get_plugin_config_schema(meta),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -463,12 +754,62 @@ 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:
|
||||
logger.error(f"插件 {meta.plugin_id} 注册失败: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _get_plugin_default_config(instance: object) -> Dict[str, Any]:
|
||||
"""获取插件默认配置。
|
||||
|
||||
Args:
|
||||
instance: 插件实例。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 默认配置;插件未声明时返回空字典。
|
||||
"""
|
||||
|
||||
if not hasattr(instance, "get_default_config"):
|
||||
return {}
|
||||
try:
|
||||
default_config = cast(_ConfigAwarePlugin, instance).get_default_config()
|
||||
except Exception as exc:
|
||||
logger.warning(f"读取插件默认配置失败: {exc}")
|
||||
return {}
|
||||
return default_config if isinstance(default_config, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _get_plugin_config_schema(meta: PluginMeta) -> Dict[str, Any]:
|
||||
"""获取插件 WebUI 配置 Schema。
|
||||
|
||||
Args:
|
||||
meta: 插件元数据。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 插件配置 Schema;插件未声明时返回空字典。
|
||||
"""
|
||||
|
||||
instance = meta.instance
|
||||
if not hasattr(instance, "get_webui_config_schema"):
|
||||
return {}
|
||||
try:
|
||||
schema = cast(_ConfigAwarePlugin, instance).get_webui_config_schema(
|
||||
plugin_id=meta.plugin_id,
|
||||
plugin_name=meta.manifest.name,
|
||||
plugin_version=meta.version,
|
||||
plugin_description=meta.manifest.description,
|
||||
plugin_author=meta.manifest.author.name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"构造插件配置 Schema 失败: {exc}")
|
||||
return {}
|
||||
return schema if isinstance(schema, dict) else {}
|
||||
|
||||
async def _unregister_plugin(self, plugin_id: str, reason: str) -> None:
|
||||
"""通知 Host 注销指定插件。
|
||||
|
||||
@@ -526,36 +867,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 两侧状态。
|
||||
@@ -632,7 +977,9 @@ class PluginRunner:
|
||||
continue
|
||||
dependency_graph[plugin_id] = {dependency for dependency in meta.dependencies if dependency in plugin_ids}
|
||||
|
||||
indegree: Dict[str, int] = {plugin_id: len(dependencies) for plugin_id, dependencies in dependency_graph.items()}
|
||||
indegree: Dict[str, int] = {
|
||||
plugin_id: len(dependencies) for plugin_id, dependencies in dependency_graph.items()
|
||||
}
|
||||
reverse_graph: Dict[str, Set[str]] = {plugin_id: set() for plugin_id in dependency_graph}
|
||||
|
||||
for plugin_id, dependencies in dependency_graph.items():
|
||||
@@ -678,9 +1025,7 @@ class PluginRunner:
|
||||
for failed_plugin_id, failure_reason in failed_plugins.items():
|
||||
rollback_failure = rollback_failures.get(failed_plugin_id)
|
||||
if rollback_failure:
|
||||
finalized_failures[failed_plugin_id] = (
|
||||
f"{failure_reason};且旧版本恢复失败: {rollback_failure}"
|
||||
)
|
||||
finalized_failures[failed_plugin_id] = f"{failure_reason};且旧版本恢复失败: {rollback_failure}"
|
||||
else:
|
||||
finalized_failures[failed_plugin_id] = f"{failure_reason}(已恢复旧版本)"
|
||||
|
||||
@@ -716,6 +1061,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,
|
||||
)
|
||||
|
||||
@@ -762,9 +1108,7 @@ class PluginRunner:
|
||||
failed_plugins=failed_plugins,
|
||||
)
|
||||
|
||||
target_plugin_ids: Set[str] = {
|
||||
plugin_id for plugin_id in reload_root_ids if plugin_id not in loaded_plugin_ids
|
||||
}
|
||||
target_plugin_ids: Set[str] = {plugin_id for plugin_id in reload_root_ids if plugin_id not in loaded_plugin_ids}
|
||||
if loaded_root_plugin_ids := reload_root_ids & loaded_plugin_ids:
|
||||
target_plugin_ids.update(self._collect_reverse_dependents_for_roots(loaded_root_plugin_ids))
|
||||
|
||||
@@ -812,6 +1156,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:
|
||||
@@ -822,10 +1168,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
|
||||
|
||||
@@ -835,9 +1199,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)
|
||||
@@ -872,7 +1240,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(
|
||||
@@ -880,29 +1248,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",
|
||||
@@ -1128,6 +1507,87 @@ 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:
|
||||
"""处理插件配置校验请求。
|
||||
|
||||
Args:
|
||||
envelope: RPC 请求信封。
|
||||
|
||||
Returns:
|
||||
Envelope: RPC 响应信封。
|
||||
"""
|
||||
|
||||
try:
|
||||
payload = ValidatePluginConfigPayload.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:
|
||||
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=inspection_result.normalized_config,
|
||||
changed=inspection_result.changed,
|
||||
)
|
||||
return envelope.make_response(payload=result.model_dump())
|
||||
|
||||
async def _handle_reload_plugin(self, envelope: Envelope) -> Envelope:
|
||||
"""处理按插件 ID 的精确重载请求。
|
||||
|
||||
@@ -1189,6 +1649,7 @@ class PluginRunner:
|
||||
|
||||
async def _async_main() -> None:
|
||||
"""异步主入口"""
|
||||
blocked_plugin_reasons_raw = os.environ.get(ENV_BLOCKED_PLUGIN_REASONS, "")
|
||||
host_address = os.environ.pop(ENV_IPC_ADDRESS, "")
|
||||
external_plugin_ids_raw = os.environ.get(ENV_EXTERNAL_PLUGIN_IDS, "")
|
||||
session_token = os.environ.pop(ENV_SESSION_TOKEN, "")
|
||||
@@ -1208,14 +1669,30 @@ async def _async_main() -> None:
|
||||
logger.warning("外部依赖插件版本映射格式非法,已回退为空映射")
|
||||
external_plugin_ids = {}
|
||||
|
||||
try:
|
||||
blocked_plugin_reasons = json.loads(blocked_plugin_reasons_raw) if blocked_plugin_reasons_raw else {}
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("解析阻止加载插件原因映射失败,已回退为空映射")
|
||||
blocked_plugin_reasons = {}
|
||||
if not isinstance(blocked_plugin_reasons, dict):
|
||||
logger.warning("阻止加载插件原因映射格式非法,已回退为空映射")
|
||||
blocked_plugin_reasons = {}
|
||||
|
||||
runner_kwargs: Dict[str, Any] = {
|
||||
"external_available_plugins": {
|
||||
str(plugin_id): str(plugin_version) for plugin_id, plugin_version in external_plugin_ids.items()
|
||||
}
|
||||
}
|
||||
if blocked_plugin_reasons:
|
||||
runner_kwargs["blocked_plugin_reasons"] = {
|
||||
str(plugin_id): str(reason) for plugin_id, reason in blocked_plugin_reasons.items()
|
||||
}
|
||||
|
||||
runner = PluginRunner(
|
||||
host_address,
|
||||
session_token,
|
||||
plugin_dirs,
|
||||
external_available_plugins={
|
||||
str(plugin_id): str(plugin_version)
|
||||
for plugin_id, plugin_version in external_plugin_ids.items()
|
||||
},
|
||||
**runner_kwargs,
|
||||
)
|
||||
|
||||
# 注册信号处理
|
||||
|
||||
Reference in New Issue
Block a user