merge: 同步 upstream/r-dev 并解决冲突

This commit is contained in:
DawnARC
2026-04-03 19:56:45 +08:00
186 changed files with 14212 additions and 6705 deletions

View File

@@ -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 对象)"""

View File

@@ -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",
]

View File

@@ -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:

View File

@@ -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("已注册全部主程序能力实现")

View 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)}

View File

@@ -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()

View 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}"

View 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

View 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]

View 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

View File

@@ -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} 不能注册为 blockingHook {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} 不能注册为 observeHook {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=abortHook {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]),

View File

@@ -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,

View 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])
]

View File

@@ -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,

View File

@@ -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

View File

@@ -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="重载失败的插件及原因")
"""重载失败的插件及原因"""

View File

@@ -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:

View File

@@ -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:

View File

@@ -9,8 +9,10 @@
6. 转发插件的能力调用到 Host
"""
from collections.abc import Mapping
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, List, 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,
)
# 注册信号处理