Refactor plugin loader and runner to support enhanced manifest structure
- Updated the PluginMeta class to utilize a strongly typed PluginManifest, improving type safety and clarity. - Refactored dependency extraction logic to streamline the handling of plugin dependencies. - Modified the PluginLoader to accommodate new manifest versioning and validation processes. - Enhanced the PluginRunner to work with a dictionary for external available plugins, allowing for version mapping. - Updated built-in plugins' manifest files to version 2, adding URLs and SDK versioning for better integration and documentation. - Improved error handling and logging for plugin loading and dependency resolution processes.
This commit is contained in:
@@ -1,58 +1,40 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"name": "发言频率控制插件|BetterFrequency Plugin",
|
||||
"manifest_version": 2,
|
||||
"version": "2.0.0",
|
||||
"description": "控制聊天频率,支持设置focus_value和talk_frequency调整值,提供命令",
|
||||
"name": "发言频率控制插件|BetterFrequency Plugin",
|
||||
"description": "控制聊天频率,支持设置 focus_value 和 talk_frequency 调整值,并提供命令入口。",
|
||||
"author": {
|
||||
"name": "SengokuCola",
|
||||
"url": "https://github.com/MaiM-with-u"
|
||||
},
|
||||
"license": "GPL-v3.0-or-later",
|
||||
"host_application": {
|
||||
"min_version": "1.0.0"
|
||||
"urls": {
|
||||
"repository": "https://github.com/SengokuCola/BetterFrequency",
|
||||
"homepage": "https://github.com/SengokuCola/BetterFrequency",
|
||||
"documentation": "https://github.com/SengokuCola/BetterFrequency",
|
||||
"issues": "https://github.com/SengokuCola/BetterFrequency/issues"
|
||||
},
|
||||
"homepage_url": "https://github.com/SengokuCola/BetterFrequency",
|
||||
"repository_url": "https://github.com/SengokuCola/BetterFrequency",
|
||||
"keywords": [
|
||||
"frequency",
|
||||
"control",
|
||||
"talk_frequency",
|
||||
"plugin",
|
||||
"shortcut"
|
||||
"host_application": {
|
||||
"min_version": "1.0.0",
|
||||
"max_version": "1.0.0"
|
||||
},
|
||||
"sdk": {
|
||||
"min_version": "2.0.0",
|
||||
"max_version": "2.99.99"
|
||||
},
|
||||
"dependencies": [],
|
||||
"capabilities": [
|
||||
"send.text",
|
||||
"frequency.set_adjust",
|
||||
"frequency.get_current_talk_value",
|
||||
"frequency.get_adjust"
|
||||
],
|
||||
"categories": [
|
||||
"Chat",
|
||||
"Frequency",
|
||||
"Control"
|
||||
],
|
||||
"default_locale": "zh-CN",
|
||||
"locales_path": "_locales",
|
||||
"plugin_info": {
|
||||
"is_built_in": false,
|
||||
"plugin_type": "frequency",
|
||||
"components": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "set_talk_frequency",
|
||||
"description": "设置当前聊天的talk_frequency调整值",
|
||||
"pattern": "/chat talk_frequency <数字> 或 /chat t <数字>"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"name": "show_frequency",
|
||||
"description": "显示当前聊天的频率控制状态",
|
||||
"pattern": "/chat show 或 /chat s"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
"设置talk_frequency调整值",
|
||||
"调整当前聊天的发言频率",
|
||||
"显示当前频率控制状态",
|
||||
"实时频率控制调整",
|
||||
"命令执行反馈(不保存消息)",
|
||||
"支持完整命令和简化命令",
|
||||
"快速操作支持"
|
||||
"i18n": {
|
||||
"default_locale": "zh-CN",
|
||||
"locales_path": "_locales",
|
||||
"supported_locales": [
|
||||
"zh-CN"
|
||||
]
|
||||
},
|
||||
"id": "SengokuCola.BetterFrequency"
|
||||
}
|
||||
"id": "sengokucola.betterfrequency"
|
||||
}
|
||||
|
||||
@@ -1,67 +1,42 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"name": "MCP桥接插件",
|
||||
"manifest_version": 2,
|
||||
"version": "2.0.0",
|
||||
"description": "将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot,使麦麦能够调用外部 MCP 工具",
|
||||
"name": "MCP桥接插件",
|
||||
"description": "将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot,使麦麦能够调用外部 MCP 工具。",
|
||||
"author": {
|
||||
"name": "CharTyr",
|
||||
"url": "https://github.com/CharTyr"
|
||||
},
|
||||
"license": "AGPL-3.0",
|
||||
"host_application": {
|
||||
"min_version": "0.11.6"
|
||||
"urls": {
|
||||
"repository": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin",
|
||||
"homepage": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin",
|
||||
"documentation": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin",
|
||||
"issues": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin/issues"
|
||||
},
|
||||
"homepage_url": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin",
|
||||
"repository_url": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin",
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"bridge",
|
||||
"tool",
|
||||
"integration",
|
||||
"resources",
|
||||
"prompts",
|
||||
"post-process",
|
||||
"cache",
|
||||
"trace",
|
||||
"permissions",
|
||||
"import",
|
||||
"export",
|
||||
"claude-desktop",
|
||||
"workflow",
|
||||
"react",
|
||||
"agent"
|
||||
"host_application": {
|
||||
"min_version": "0.11.6",
|
||||
"max_version": "1.0.0"
|
||||
},
|
||||
"sdk": {
|
||||
"min_version": "2.0.0",
|
||||
"max_version": "2.99.99"
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"type": "python_package",
|
||||
"name": "mcp",
|
||||
"version_spec": ">=0.0.0"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"工具扩展",
|
||||
"外部集成"
|
||||
"capabilities": [
|
||||
"send.text"
|
||||
],
|
||||
"default_locale": "zh-CN",
|
||||
"plugin_info": {
|
||||
"is_built_in": false,
|
||||
"components": [],
|
||||
"features": [
|
||||
"支持多个 MCP 服务器",
|
||||
"自动发现并注册 MCP 工具",
|
||||
"支持 stdio、SSE、HTTP、Streamable HTTP 四种传输方式",
|
||||
"工具参数自动转换",
|
||||
"心跳检测与自动重连",
|
||||
"调用统计(次数、成功率、耗时)",
|
||||
"WebUI 配置支持",
|
||||
"Resources 支持(实验性)",
|
||||
"Prompts 支持(实验性)",
|
||||
"结果后处理(LLM 摘要提炼)",
|
||||
"工具禁用管理",
|
||||
"调用链路追踪",
|
||||
"工具调用缓存(LRU)",
|
||||
"工具权限控制(群/用户级别)",
|
||||
"配置导入导出(Claude Desktop mcpServers)",
|
||||
"断路器模式(故障快速失败)",
|
||||
"状态实时刷新",
|
||||
"Workflow 硬流程(顺序执行多个工具)",
|
||||
"Workflow 快速添加(表单式配置)",
|
||||
"ReAct 软流程(LLM 自主多轮调用)",
|
||||
"双轨制架构(软流程 + 硬流程)"
|
||||
"i18n": {
|
||||
"default_locale": "zh-CN",
|
||||
"supported_locales": [
|
||||
"zh-CN"
|
||||
]
|
||||
},
|
||||
"id": "MaiBot Community.MCPBridgePlugin"
|
||||
"id": "chartyr.mcpbridge-plugin"
|
||||
}
|
||||
|
||||
@@ -1,68 +1,44 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"name": "BetterEmoji",
|
||||
"manifest_version": 2,
|
||||
"version": "2.0.0",
|
||||
"name": "BetterEmoji",
|
||||
"description": "更好的表情包管理插件",
|
||||
"author": {
|
||||
"name": "SengokuCola",
|
||||
"url": "https://github.com/SengokuCola"
|
||||
},
|
||||
"license": "GPL-v3.0-or-later",
|
||||
"host_application": {
|
||||
"min_version": "1.0.0"
|
||||
"urls": {
|
||||
"repository": "https://github.com/SengokuCola/BetterEmoji",
|
||||
"homepage": "https://github.com/SengokuCola/BetterEmoji",
|
||||
"documentation": "https://github.com/SengokuCola/BetterEmoji",
|
||||
"issues": "https://github.com/SengokuCola/BetterEmoji/issues"
|
||||
},
|
||||
"homepage_url": "https://github.com/SengokuCola/BetterEmoji",
|
||||
"repository_url": "https://github.com/SengokuCola/BetterEmoji",
|
||||
"keywords": [
|
||||
"emoji",
|
||||
"manage",
|
||||
"plugin"
|
||||
"host_application": {
|
||||
"min_version": "1.0.0",
|
||||
"max_version": "1.0.0"
|
||||
},
|
||||
"sdk": {
|
||||
"min_version": "2.0.0",
|
||||
"max_version": "2.99.99"
|
||||
},
|
||||
"dependencies": [],
|
||||
"capabilities": [
|
||||
"emoji.get_random",
|
||||
"emoji.get_count",
|
||||
"emoji.get_info",
|
||||
"emoji.get_all",
|
||||
"emoji.register_emoji",
|
||||
"emoji.delete_emoji",
|
||||
"send.text",
|
||||
"send.forward"
|
||||
],
|
||||
"categories": [
|
||||
"Emoji",
|
||||
"Management"
|
||||
],
|
||||
"default_locale": "zh-CN",
|
||||
"locales_path": "_locales",
|
||||
"plugin_info": {
|
||||
"is_built_in": false,
|
||||
"plugin_type": "emoji_manage",
|
||||
"capabilities": [
|
||||
"emoji.get_random",
|
||||
"emoji.get_count",
|
||||
"emoji.get_info",
|
||||
"emoji.get_all",
|
||||
"emoji.register_emoji",
|
||||
"emoji.delete_emoji",
|
||||
"send.text",
|
||||
"send.forward"
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "add_emoji",
|
||||
"description": "添加表情包",
|
||||
"pattern": "/emoji add"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"name": "emoji_list",
|
||||
"description": "列表表情包",
|
||||
"pattern": "/emoji list"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"name": "delete_emoji",
|
||||
"description": "删除表情包",
|
||||
"pattern": "/emoji delete"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"name": "random_emojis",
|
||||
"description": "发送多张随机表情包",
|
||||
"pattern": "/random_emojis"
|
||||
}
|
||||
"i18n": {
|
||||
"default_locale": "zh-CN",
|
||||
"locales_path": "_locales",
|
||||
"supported_locales": [
|
||||
"zh-CN"
|
||||
]
|
||||
},
|
||||
"id": "SengokuCola.BetterEmoji"
|
||||
}
|
||||
"id": "sengokucola.betteremoji"
|
||||
}
|
||||
|
||||
@@ -1,88 +1,41 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"name": "Hello World 示例插件 (Hello World Plugin)",
|
||||
"manifest_version": 2,
|
||||
"version": "2.0.0",
|
||||
"description": "我的第一个MaiCore插件,包含问候功能和时间查询等基础示例",
|
||||
"name": "Hello World 示例插件 (Hello World Plugin)",
|
||||
"description": "我的第一个 MaiCore 插件,包含问候功能和时间查询等基础示例",
|
||||
"author": {
|
||||
"name": "MaiBot开发团队",
|
||||
"url": "https://github.com/MaiM-with-u"
|
||||
},
|
||||
"license": "GPL-v3.0-or-later",
|
||||
"host_application": {
|
||||
"min_version": "1.0.0"
|
||||
"urls": {
|
||||
"repository": "https://github.com/MaiM-with-u/maibot",
|
||||
"homepage": "https://github.com/MaiM-with-u/maibot",
|
||||
"documentation": "https://github.com/MaiM-with-u/maibot",
|
||||
"issues": "https://github.com/MaiM-with-u/maibot/issues"
|
||||
},
|
||||
"homepage_url": "https://github.com/MaiM-with-u/maibot",
|
||||
"repository_url": "https://github.com/MaiM-with-u/maibot",
|
||||
"keywords": [
|
||||
"demo",
|
||||
"example",
|
||||
"hello",
|
||||
"greeting",
|
||||
"tutorial"
|
||||
"host_application": {
|
||||
"min_version": "1.0.0",
|
||||
"max_version": "1.0.0"
|
||||
},
|
||||
"sdk": {
|
||||
"min_version": "2.0.0",
|
||||
"max_version": "2.99.99"
|
||||
},
|
||||
"dependencies": [],
|
||||
"capabilities": [
|
||||
"send.text",
|
||||
"send.forward",
|
||||
"send.hybrid",
|
||||
"emoji.get_random",
|
||||
"config.get"
|
||||
],
|
||||
"categories": [
|
||||
"Examples",
|
||||
"Tutorial"
|
||||
],
|
||||
"default_locale": "zh-CN",
|
||||
"locales_path": "_locales",
|
||||
"plugin_info": {
|
||||
"is_built_in": false,
|
||||
"plugin_type": "example",
|
||||
"capabilities": [
|
||||
"send.text",
|
||||
"send.forward",
|
||||
"send.hybrid",
|
||||
"emoji.get_random",
|
||||
"config.get"
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"type": "tool",
|
||||
"name": "compare_numbers",
|
||||
"description": "比较两个数的大小"
|
||||
},
|
||||
{
|
||||
"type": "action",
|
||||
"name": "hello_greeting",
|
||||
"description": "向用户发送问候消息"
|
||||
},
|
||||
{
|
||||
"type": "action",
|
||||
"name": "bye_greeting",
|
||||
"description": "向用户发送告别消息",
|
||||
"activation_modes": ["keyword"],
|
||||
"keywords": ["再见", "bye", "88", "拜拜"]
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"name": "time",
|
||||
"description": "查询当前时间",
|
||||
"pattern": "/time"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"name": "random_emojis",
|
||||
"description": "发送多张随机表情包",
|
||||
"pattern": "/random_emojis"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"name": "test",
|
||||
"description": "测试命令",
|
||||
"pattern": "/test"
|
||||
},
|
||||
{
|
||||
"type": "event_handler",
|
||||
"name": "print_message_handler",
|
||||
"description": "打印接收到的消息"
|
||||
},
|
||||
{
|
||||
"type": "event_handler",
|
||||
"name": "forward_messages_handler",
|
||||
"description": "把接收到的消息转发到指定聊天ID"
|
||||
}
|
||||
"i18n": {
|
||||
"default_locale": "zh-CN",
|
||||
"locales_path": "_locales",
|
||||
"supported_locales": [
|
||||
"zh-CN"
|
||||
]
|
||||
},
|
||||
"id": "MaiBot开发团队.maibot"
|
||||
}
|
||||
"id": "maibot-team.hello-world-plugin"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,104 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "packages", "maibot-plugin-sdk"))
|
||||
|
||||
|
||||
def build_test_manifest(
|
||||
plugin_id: str,
|
||||
*,
|
||||
version: str = "1.0.0",
|
||||
name: str = "测试插件",
|
||||
description: str = "测试插件描述",
|
||||
dependencies: list[dict[str, str]] | None = None,
|
||||
capabilities: list[str] | None = None,
|
||||
host_min_version: str = "0.12.0",
|
||||
host_max_version: str = "1.0.0",
|
||||
sdk_min_version: str = "2.0.0",
|
||||
sdk_max_version: str = "2.99.99",
|
||||
) -> dict[str, object]:
|
||||
"""构造一个合法的 Manifest v2 测试样例。
|
||||
|
||||
Args:
|
||||
plugin_id: 插件 ID。
|
||||
version: 插件版本。
|
||||
name: 展示名称。
|
||||
description: 插件描述。
|
||||
dependencies: 依赖声明列表。
|
||||
capabilities: 能力声明列表。
|
||||
host_min_version: Host 最低支持版本。
|
||||
host_max_version: Host 最高支持版本。
|
||||
sdk_min_version: SDK 最低支持版本。
|
||||
sdk_max_version: SDK 最高支持版本。
|
||||
|
||||
Returns:
|
||||
dict[str, object]: 可直接序列化为 ``_manifest.json`` 的字典。
|
||||
"""
|
||||
return {
|
||||
"manifest_version": 2,
|
||||
"version": version,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"author": {
|
||||
"name": "tester",
|
||||
"url": "https://example.com/tester",
|
||||
},
|
||||
"license": "MIT",
|
||||
"urls": {
|
||||
"repository": f"https://example.com/{plugin_id}",
|
||||
},
|
||||
"host_application": {
|
||||
"min_version": host_min_version,
|
||||
"max_version": host_max_version,
|
||||
},
|
||||
"sdk": {
|
||||
"min_version": sdk_min_version,
|
||||
"max_version": sdk_max_version,
|
||||
},
|
||||
"dependencies": dependencies or [],
|
||||
"capabilities": capabilities or [],
|
||||
"i18n": {
|
||||
"default_locale": "zh-CN",
|
||||
"supported_locales": ["zh-CN"],
|
||||
},
|
||||
"id": plugin_id,
|
||||
}
|
||||
|
||||
|
||||
def build_test_manifest_model(
|
||||
plugin_id: str,
|
||||
*,
|
||||
version: str = "1.0.0",
|
||||
dependencies: list[dict[str, str]] | None = None,
|
||||
capabilities: list[str] | None = None,
|
||||
host_version: str = "1.0.0",
|
||||
sdk_version: str = "2.0.1",
|
||||
) -> object:
|
||||
"""构造一个已经通过校验的强类型 Manifest 测试对象。
|
||||
|
||||
Args:
|
||||
plugin_id: 插件 ID。
|
||||
version: 插件版本。
|
||||
dependencies: 依赖声明列表。
|
||||
capabilities: 能力声明列表。
|
||||
host_version: 当前测试使用的 Host 版本。
|
||||
sdk_version: 当前测试使用的 SDK 版本。
|
||||
|
||||
Returns:
|
||||
object: ``PluginManifest`` 实例。
|
||||
"""
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
|
||||
validator = ManifestValidator(host_version=host_version, sdk_version=sdk_version)
|
||||
manifest = validator.parse_manifest(
|
||||
build_test_manifest(
|
||||
plugin_id,
|
||||
version=version,
|
||||
dependencies=dependencies,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
)
|
||||
assert manifest is not None
|
||||
return manifest
|
||||
|
||||
|
||||
# ─── 协议层测试 ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -759,65 +857,77 @@ class TestManifestValidator:
|
||||
def test_valid_manifest(self):
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
|
||||
validator = ManifestValidator()
|
||||
manifest = {
|
||||
"manifest_version": 1,
|
||||
"name": "test_plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "测试插件",
|
||||
"author": "test",
|
||||
}
|
||||
validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1")
|
||||
manifest = build_test_manifest("test.valid-plugin", capabilities=["send.text"])
|
||||
assert validator.validate(manifest) is True
|
||||
assert len(validator.errors) == 0
|
||||
assert validator.warnings == []
|
||||
|
||||
def test_missing_required_fields(self):
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
|
||||
validator = ManifestValidator()
|
||||
manifest = {"manifest_version": 1}
|
||||
validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1")
|
||||
manifest = {"manifest_version": 2}
|
||||
assert validator.validate(manifest) is False
|
||||
assert len(validator.errors) >= 4 # name, version, description, author
|
||||
assert len(validator.errors) >= 6
|
||||
assert any("缺少必需字段" in error for error in validator.errors)
|
||||
|
||||
def test_unsupported_manifest_version(self):
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
|
||||
validator = ManifestValidator()
|
||||
manifest = {
|
||||
"manifest_version": 999,
|
||||
"name": "test",
|
||||
"version": "1.0",
|
||||
"description": "d",
|
||||
"author": "a",
|
||||
}
|
||||
validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1")
|
||||
manifest = build_test_manifest("test.invalid-version")
|
||||
manifest["manifest_version"] = 999
|
||||
assert validator.validate(manifest) is False
|
||||
assert any("manifest_version" in e for e in validator.errors)
|
||||
|
||||
def test_host_version_compatibility(self):
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
|
||||
validator = ManifestValidator(host_version="0.8.5")
|
||||
manifest = {
|
||||
"name": "test",
|
||||
"version": "1.0",
|
||||
"description": "d",
|
||||
"author": "a",
|
||||
"host_application": {"min_version": "0.9.0"},
|
||||
}
|
||||
validator = ManifestValidator(host_version="0.8.5", sdk_version="2.0.1")
|
||||
manifest = build_test_manifest(
|
||||
"test.host-check",
|
||||
host_min_version="0.9.0",
|
||||
host_max_version="1.0.0",
|
||||
)
|
||||
assert validator.validate(manifest) is False
|
||||
assert any("Host 版本不兼容" in e for e in validator.errors)
|
||||
|
||||
def test_recommended_fields_warning(self):
|
||||
def test_sdk_version_compatibility(self):
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
|
||||
validator = ManifestValidator()
|
||||
manifest = {
|
||||
"name": "test",
|
||||
"version": "1.0",
|
||||
"description": "d",
|
||||
"author": "a",
|
||||
}
|
||||
validator.validate(manifest)
|
||||
assert len(validator.warnings) >= 3 # license, keywords, categories
|
||||
validator = ManifestValidator(host_version="1.0.0", sdk_version="1.9.9")
|
||||
manifest = build_test_manifest("test.sdk-check")
|
||||
assert validator.validate(manifest) is False
|
||||
assert any("SDK 版本不兼容" in e for e in validator.errors)
|
||||
|
||||
def test_extra_fields_are_rejected(self):
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
|
||||
validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1")
|
||||
manifest = build_test_manifest("test.extra-field")
|
||||
manifest["unexpected"] = True
|
||||
|
||||
assert validator.validate(manifest) is False
|
||||
assert any("存在未声明字段" in error for error in validator.errors)
|
||||
|
||||
def test_python_package_conflict_rejects_manifest(self):
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
|
||||
validator = ManifestValidator(host_version="1.0.0", sdk_version="2.0.1")
|
||||
manifest = build_test_manifest(
|
||||
"test.numpy-conflict",
|
||||
dependencies=[
|
||||
{
|
||||
"type": "python_package",
|
||||
"name": "numpy",
|
||||
"version_spec": ">=999.0.0",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert validator.validate(manifest) is False
|
||||
assert any("Python 包依赖冲突" in error for error in validator.errors)
|
||||
|
||||
|
||||
class TestVersionComparator:
|
||||
@@ -859,59 +969,83 @@ class TestDependencyResolution:
|
||||
|
||||
loader = PluginLoader()
|
||||
candidates = {
|
||||
"core": ("dir_core", {"name": "core", "version": "1.0", "description": "d", "author": "a"}, "plugin.py"),
|
||||
"auth": (
|
||||
"dir_auth",
|
||||
{"name": "auth", "version": "1.0", "description": "d", "author": "a", "dependencies": ["core"]},
|
||||
"test.core": (
|
||||
"dir_core",
|
||||
build_test_manifest_model("test.core"),
|
||||
"plugin.py",
|
||||
),
|
||||
"api": (
|
||||
"test.auth": (
|
||||
"dir_auth",
|
||||
build_test_manifest_model(
|
||||
"test.auth",
|
||||
dependencies=[
|
||||
{"type": "plugin", "id": "test.core", "version_spec": ">=1.0.0,<2.0.0"},
|
||||
],
|
||||
),
|
||||
"plugin.py",
|
||||
),
|
||||
"test.api": (
|
||||
"dir_api",
|
||||
{"name": "api", "version": "1.0", "description": "d", "author": "a", "dependencies": ["core", "auth"]},
|
||||
build_test_manifest_model(
|
||||
"test.api",
|
||||
dependencies=[
|
||||
{"type": "plugin", "id": "test.core", "version_spec": ">=1.0.0,<2.0.0"},
|
||||
{"type": "plugin", "id": "test.auth", "version_spec": ">=1.0.0,<2.0.0"},
|
||||
],
|
||||
),
|
||||
"plugin.py",
|
||||
),
|
||||
}
|
||||
|
||||
order, failed = loader._resolve_dependencies(candidates)
|
||||
assert len(failed) == 0
|
||||
assert order.index("core") < order.index("auth")
|
||||
assert order.index("auth") < order.index("api")
|
||||
assert order.index("test.core") < order.index("test.auth")
|
||||
assert order.index("test.auth") < order.index("test.api")
|
||||
|
||||
def test_missing_dependency(self):
|
||||
from src.plugin_runtime.runner.plugin_loader import PluginLoader
|
||||
|
||||
loader = PluginLoader()
|
||||
candidates = {
|
||||
"plugin_a": (
|
||||
"test.plugin-a": (
|
||||
"dir_a",
|
||||
{
|
||||
"name": "plugin_a",
|
||||
"version": "1.0",
|
||||
"description": "d",
|
||||
"author": "a",
|
||||
"dependencies": ["nonexistent"],
|
||||
},
|
||||
build_test_manifest_model(
|
||||
"test.plugin-a",
|
||||
dependencies=[
|
||||
{"type": "plugin", "id": "test.nonexistent", "version_spec": ">=1.0.0,<2.0.0"},
|
||||
],
|
||||
),
|
||||
"plugin.py",
|
||||
),
|
||||
}
|
||||
|
||||
order, failed = loader._resolve_dependencies(candidates)
|
||||
assert "plugin_a" in failed
|
||||
assert "缺少依赖" in failed["plugin_a"]
|
||||
assert "test.plugin-a" in failed
|
||||
assert "依赖未满足" in failed["test.plugin-a"]
|
||||
|
||||
def test_circular_dependency(self):
|
||||
from src.plugin_runtime.runner.plugin_loader import PluginLoader
|
||||
|
||||
loader = PluginLoader()
|
||||
candidates = {
|
||||
"a": (
|
||||
"test.a": (
|
||||
"dir_a",
|
||||
{"name": "a", "version": "1.0", "description": "d", "author": "x", "dependencies": ["b"]},
|
||||
build_test_manifest_model(
|
||||
"test.a",
|
||||
dependencies=[
|
||||
{"type": "plugin", "id": "test.b", "version_spec": ">=1.0.0,<2.0.0"},
|
||||
],
|
||||
),
|
||||
"p.py",
|
||||
),
|
||||
"b": (
|
||||
"test.b": (
|
||||
"dir_b",
|
||||
{"name": "b", "version": "1.0", "description": "d", "author": "x", "dependencies": ["a"]},
|
||||
build_test_manifest_model(
|
||||
"test.b",
|
||||
dependencies=[
|
||||
{"type": "plugin", "id": "test.a", "version_spec": ">=1.0.0,<2.0.0"},
|
||||
],
|
||||
),
|
||||
"p.py",
|
||||
),
|
||||
}
|
||||
@@ -929,12 +1063,11 @@ class TestDependencyResolution:
|
||||
|
||||
(plugin_dir / "_manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "grok_search_plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "demo",
|
||||
"author": "tester",
|
||||
}
|
||||
build_test_manifest(
|
||||
"test.grok-search-plugin",
|
||||
name="grok_search_plugin",
|
||||
description="demo",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -954,7 +1087,7 @@ class TestDependencyResolution:
|
||||
loader = PluginLoader()
|
||||
loaded = loader.discover_and_load([str(plugin_root)])
|
||||
|
||||
assert [meta.plugin_id for meta in loaded] == ["grok_search_plugin"]
|
||||
assert [meta.plugin_id for meta in loaded] == ["test.grok-search-plugin"]
|
||||
assert loader.failed_plugins == {}
|
||||
assert loaded[0].instance.answer() == 42
|
||||
|
||||
@@ -968,12 +1101,11 @@ class TestDependencyResolution:
|
||||
|
||||
(plugin_dir / "_manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "demo_plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "demo",
|
||||
"author": "tester",
|
||||
}
|
||||
build_test_manifest(
|
||||
"test.demo-plugin",
|
||||
name="demo_plugin",
|
||||
description="demo",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -993,8 +1125,8 @@ class TestDependencyResolution:
|
||||
loaded = loader.discover_and_load([str(plugin_root)])
|
||||
|
||||
assert loaded == []
|
||||
assert "demo_plugin" in loader.failed_plugins
|
||||
assert "on_config_update" in loader.failed_plugins["demo_plugin"]
|
||||
assert "test.demo-plugin" in loader.failed_plugins
|
||||
assert "on_config_update" in loader.failed_plugins["test.demo-plugin"]
|
||||
|
||||
def test_loader_requires_sdk_plugin_to_override_on_load(self, tmp_path):
|
||||
from src.plugin_runtime.runner.plugin_loader import PluginLoader
|
||||
@@ -1006,12 +1138,11 @@ class TestDependencyResolution:
|
||||
|
||||
(plugin_dir / "_manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "demo_plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "demo",
|
||||
"author": "tester",
|
||||
}
|
||||
build_test_manifest(
|
||||
"test.demo-plugin",
|
||||
name="demo_plugin",
|
||||
description="demo",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -1031,8 +1162,8 @@ class TestDependencyResolution:
|
||||
loaded = loader.discover_and_load([str(plugin_root)])
|
||||
|
||||
assert loaded == []
|
||||
assert "demo_plugin" in loader.failed_plugins
|
||||
assert "on_load" in loader.failed_plugins["demo_plugin"]
|
||||
assert "test.demo-plugin" in loader.failed_plugins
|
||||
assert "on_load" in loader.failed_plugins["test.demo-plugin"]
|
||||
|
||||
def test_loader_requires_sdk_plugin_to_override_on_unload(self, tmp_path):
|
||||
from src.plugin_runtime.runner.plugin_loader import PluginLoader
|
||||
@@ -1044,12 +1175,11 @@ class TestDependencyResolution:
|
||||
|
||||
(plugin_dir / "_manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "demo_plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "demo",
|
||||
"author": "tester",
|
||||
}
|
||||
build_test_manifest(
|
||||
"test.demo-plugin",
|
||||
name="demo_plugin",
|
||||
description="demo",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -1069,8 +1199,8 @@ class TestDependencyResolution:
|
||||
loaded = loader.discover_and_load([str(plugin_root)])
|
||||
|
||||
assert loaded == []
|
||||
assert "demo_plugin" in loader.failed_plugins
|
||||
assert "on_unload" in loader.failed_plugins["demo_plugin"]
|
||||
assert "test.demo-plugin" in loader.failed_plugins
|
||||
assert "on_unload" in loader.failed_plugins["test.demo-plugin"]
|
||||
|
||||
def test_isolate_sys_path_preserves_plugin_dirs(self):
|
||||
from src.plugin_runtime.runner import runner_main
|
||||
@@ -2374,16 +2504,19 @@ class TestIntegration:
|
||||
def __init__(self, plugin_dirs=None, socket_path=None):
|
||||
self._plugin_dirs = plugin_dirs or []
|
||||
self.capability_service = FakeCapabilityService()
|
||||
self.external_plugin_ids = []
|
||||
self.external_plugin_versions = {}
|
||||
self.stopped = False
|
||||
instances.append(self)
|
||||
|
||||
def set_external_available_plugin_ids(self, plugin_ids):
|
||||
self.external_plugin_ids = list(plugin_ids)
|
||||
def set_external_available_plugins(self, plugin_versions):
|
||||
self.external_plugin_versions = dict(plugin_versions)
|
||||
|
||||
def get_loaded_plugin_ids(self):
|
||||
return []
|
||||
|
||||
def get_loaded_plugin_versions(self):
|
||||
return {}
|
||||
|
||||
async def start(self):
|
||||
if len(instances) == 2 and self is instances[1]:
|
||||
raise RuntimeError("boom")
|
||||
@@ -2425,8 +2558,8 @@ class TestIntegration:
|
||||
(beta_dir / "config.toml").write_text("enabled = false\n", encoding="utf-8")
|
||||
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
|
||||
(beta_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
|
||||
(alpha_dir / "_manifest.json").write_text(json.dumps({"name": "alpha"}), encoding="utf-8")
|
||||
(beta_dir / "_manifest.json").write_text(json.dumps({"name": "beta"}), encoding="utf-8")
|
||||
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
|
||||
(beta_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.beta")), encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
@@ -2440,8 +2573,11 @@ class TestIntegration:
|
||||
def get_loaded_plugin_ids(self):
|
||||
return sorted(self._registered_plugins.keys())
|
||||
|
||||
def get_loaded_plugin_versions(self):
|
||||
return {plugin_id: "1.0.0" for plugin_id in self._registered_plugins}
|
||||
|
||||
async def reload_plugins(self, plugin_ids=None, reason="manual", external_available_plugins=None):
|
||||
self.reload_reasons.append((plugin_ids, reason, external_available_plugins or []))
|
||||
self.reload_reasons.append((plugin_ids, reason, external_available_plugins or {}))
|
||||
|
||||
async def notify_plugin_config_updated(self, plugin_id, config_data, config_version=""):
|
||||
self.config_updates.append((plugin_id, config_data, config_version))
|
||||
@@ -2449,8 +2585,8 @@ class TestIntegration:
|
||||
|
||||
manager = integration_module.PluginRuntimeManager()
|
||||
manager._started = True
|
||||
manager._builtin_supervisor = FakeSupervisor([builtin_root], {"alpha": object()})
|
||||
manager._third_party_supervisor = FakeSupervisor([thirdparty_root], {"beta": object()})
|
||||
manager._builtin_supervisor = FakeSupervisor([builtin_root], {"test.alpha": object()})
|
||||
manager._third_party_supervisor = FakeSupervisor([thirdparty_root], {"test.beta": object()})
|
||||
|
||||
changes = [
|
||||
FileChange(change_type=1, path=beta_dir / "plugin.py"),
|
||||
@@ -2466,7 +2602,9 @@ class TestIntegration:
|
||||
await manager._handle_plugin_source_changes(changes)
|
||||
|
||||
assert manager._builtin_supervisor.reload_reasons == []
|
||||
assert manager._third_party_supervisor.reload_reasons == [(["beta"], "file_watcher", ["alpha"])]
|
||||
assert manager._third_party_supervisor.reload_reasons == [
|
||||
(["test.beta"], "file_watcher", {"test.alpha": "1.0.0"})
|
||||
]
|
||||
assert manager._builtin_supervisor.config_updates == []
|
||||
assert manager._third_party_supervisor.config_updates == []
|
||||
assert refresh_calls == [True]
|
||||
@@ -2487,15 +2625,18 @@ class TestIntegration:
|
||||
def get_loaded_plugin_ids(self):
|
||||
return sorted(self._registered_plugins.keys())
|
||||
|
||||
def get_loaded_plugin_versions(self):
|
||||
return {plugin_id: "1.0.0" for plugin_id in self._registered_plugins}
|
||||
|
||||
async def reload_plugins(self, plugin_ids=None, reason="manual", external_available_plugins=None):
|
||||
self.reload_calls.append((plugin_ids, reason, sorted(external_available_plugins or [])))
|
||||
self.reload_calls.append((plugin_ids, reason, dict(sorted((external_available_plugins or {}).items()))))
|
||||
return True
|
||||
|
||||
builtin_supervisor = FakeSupervisor({"alpha": FakeRegistration([])})
|
||||
builtin_supervisor = FakeSupervisor({"test.alpha": FakeRegistration([])})
|
||||
third_party_supervisor = FakeSupervisor(
|
||||
{
|
||||
"beta": FakeRegistration(["alpha"]),
|
||||
"gamma": FakeRegistration(["beta"]),
|
||||
"test.beta": FakeRegistration(["test.alpha"]),
|
||||
"test.gamma": FakeRegistration(["test.beta"]),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2510,13 +2651,15 @@ class TestIntegration:
|
||||
lambda message: warning_messages.append(message),
|
||||
)
|
||||
|
||||
reloaded = await manager.reload_plugins_globally(["alpha"], reason="manual")
|
||||
reloaded = await manager.reload_plugins_globally(["test.alpha"], reason="manual")
|
||||
|
||||
assert reloaded is True
|
||||
assert builtin_supervisor.reload_calls == [(["alpha"], "manual", ["beta", "gamma"])]
|
||||
assert builtin_supervisor.reload_calls == [
|
||||
(["test.alpha"], "manual", {"test.beta": "1.0.0", "test.gamma": "1.0.0"})
|
||||
]
|
||||
assert third_party_supervisor.reload_calls == []
|
||||
assert len(warning_messages) == 1
|
||||
assert "beta, gamma" in warning_messages[0]
|
||||
assert "test.beta, test.gamma" in warning_messages[0]
|
||||
assert "跨 Supervisor API 调用仍然可用" in warning_messages[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2535,8 +2678,8 @@ class TestIntegration:
|
||||
(beta_dir / "config.toml").write_text("enabled = false\n", encoding="utf-8")
|
||||
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
|
||||
(beta_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
|
||||
(alpha_dir / "_manifest.json").write_text(json.dumps({"name": "alpha"}), encoding="utf-8")
|
||||
(beta_dir / "_manifest.json").write_text(json.dumps({"name": "beta"}), encoding="utf-8")
|
||||
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
|
||||
(beta_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.beta")), encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
@@ -2558,15 +2701,15 @@ class TestIntegration:
|
||||
|
||||
manager = integration_module.PluginRuntimeManager()
|
||||
manager._started = True
|
||||
manager._builtin_supervisor = FakeSupervisor([builtin_root], ["alpha"])
|
||||
manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["beta"])
|
||||
manager._builtin_supervisor = FakeSupervisor([builtin_root], ["test.alpha"])
|
||||
manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["test.beta"])
|
||||
|
||||
await manager._handle_plugin_config_changes(
|
||||
"alpha",
|
||||
"test.alpha",
|
||||
[FileChange(change_type=1, path=alpha_dir / "config.toml")],
|
||||
)
|
||||
|
||||
assert manager._builtin_supervisor.config_updates == [("alpha", {"enabled": True}, "", "self")]
|
||||
assert manager._builtin_supervisor.config_updates == [("test.alpha", {"enabled": True}, "", "self")]
|
||||
assert manager._third_party_supervisor.config_updates == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2615,23 +2758,23 @@ class TestIntegration:
|
||||
manager._started = True
|
||||
manager._builtin_supervisor = FakeSupervisor(
|
||||
{
|
||||
"alpha": FakeRegistration(["bot"]),
|
||||
"beta": FakeRegistration([]),
|
||||
"test.alpha": FakeRegistration(["bot"]),
|
||||
"test.beta": FakeRegistration([]),
|
||||
}
|
||||
)
|
||||
manager._third_party_supervisor = FakeSupervisor(
|
||||
{
|
||||
"gamma": FakeRegistration(["model"]),
|
||||
"test.gamma": FakeRegistration(["model"]),
|
||||
}
|
||||
)
|
||||
|
||||
await manager._handle_main_config_reload(["bot", "model"])
|
||||
|
||||
assert manager._builtin_supervisor.config_updates == [
|
||||
("alpha", {"bot": {"name": "MaiBot"}}, "", "bot")
|
||||
("test.alpha", {"bot": {"name": "MaiBot"}}, "", "bot")
|
||||
]
|
||||
assert manager._third_party_supervisor.config_updates == [
|
||||
("gamma", {"models": [{"name": "demo"}]}, "", "model")
|
||||
("test.gamma", {"models": [{"name": "demo"}]}, "", "model")
|
||||
]
|
||||
|
||||
def test_refresh_plugin_config_watch_subscriptions_registers_per_plugin(self, tmp_path):
|
||||
@@ -2646,8 +2789,8 @@ class TestIntegration:
|
||||
beta_dir.mkdir(parents=True)
|
||||
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
|
||||
(beta_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
|
||||
(alpha_dir / "_manifest.json").write_text(json.dumps({"name": "alpha"}), encoding="utf-8")
|
||||
(beta_dir / "_manifest.json").write_text(json.dumps({"name": "beta"}), encoding="utf-8")
|
||||
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
|
||||
(beta_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.beta")), encoding="utf-8")
|
||||
|
||||
class FakeWatcher:
|
||||
def __init__(self):
|
||||
@@ -2670,12 +2813,12 @@ class TestIntegration:
|
||||
|
||||
manager = integration_module.PluginRuntimeManager()
|
||||
manager._plugin_file_watcher = FakeWatcher()
|
||||
manager._builtin_supervisor = FakeSupervisor([builtin_root], ["alpha"])
|
||||
manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["beta"])
|
||||
manager._builtin_supervisor = FakeSupervisor([builtin_root], ["test.alpha"])
|
||||
manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["test.beta"])
|
||||
|
||||
manager._refresh_plugin_config_watch_subscriptions()
|
||||
|
||||
assert set(manager._plugin_config_watcher_subscriptions.keys()) == {"alpha", "beta"}
|
||||
assert set(manager._plugin_config_watcher_subscriptions.keys()) == {"test.alpha", "test.beta"}
|
||||
assert {
|
||||
subscription["paths"][0] for subscription in manager._plugin_file_watcher.subscriptions
|
||||
} == {alpha_dir / "config.toml", beta_dir / "config.toml"}
|
||||
|
||||
@@ -18,7 +18,7 @@ ENV_HOST_VERSION = "MAIBOT_HOST_VERSION"
|
||||
"""Runner 读取的 Host 应用版本号,用于 manifest 兼容性校验"""
|
||||
|
||||
ENV_EXTERNAL_PLUGIN_IDS = "MAIBOT_EXTERNAL_PLUGIN_IDS"
|
||||
"""Runner 启动时可视为已满足的外部插件依赖列表(JSON 数组)"""
|
||||
"""Runner 启动时可视为已满足的外部插件依赖版本映射(JSON 对象)"""
|
||||
|
||||
ENV_GLOBAL_CONFIG_SNAPSHOT = "MAIBOT_GLOBAL_CONFIG_SNAPSHOT"
|
||||
"""Runner 启动时注入的全局配置快照(JSON 对象)"""
|
||||
|
||||
@@ -191,7 +191,7 @@ class RuntimeComponentCapabilityMixin:
|
||||
return None, None, "缺少必要参数 api_name"
|
||||
|
||||
if "." in normalized_api_name:
|
||||
target_plugin_id, target_api_name = normalized_api_name.split(".", 1)
|
||||
target_plugin_id, target_api_name = normalized_api_name.rsplit(".", 1)
|
||||
try:
|
||||
supervisor = self._get_supervisor_for_plugin(target_plugin_id)
|
||||
except RuntimeError as exc:
|
||||
@@ -282,7 +282,7 @@ class RuntimeComponentCapabilityMixin:
|
||||
return None, None, "缺少必要参数 name"
|
||||
|
||||
if "." in normalized_name:
|
||||
plugin_id, api_name = normalized_name.split(".", 1)
|
||||
plugin_id, api_name = normalized_name.rsplit(".", 1)
|
||||
try:
|
||||
supervisor = self._get_supervisor_for_plugin(plugin_id)
|
||||
except RuntimeError as exc:
|
||||
|
||||
@@ -116,7 +116,7 @@ class PluginRunnerSupervisor:
|
||||
self._runner_process: Optional[asyncio.subprocess.Process] = None
|
||||
self._registered_plugins: Dict[str, RegisterPluginPayload] = {}
|
||||
self._message_gateway_states: Dict[str, Dict[str, _MessageGatewayRuntimeState]] = {}
|
||||
self._external_available_plugin_ids: List[str] = []
|
||||
self._external_available_plugins: 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
|
||||
@@ -166,21 +166,34 @@ class PluginRunnerSupervisor:
|
||||
"""返回底层 RPC 服务端。"""
|
||||
return self._rpc_server
|
||||
|
||||
def set_external_available_plugin_ids(self, plugin_ids: List[str]) -> None:
|
||||
"""设置当前 Runner 启动/重载时可视为已满足的外部依赖列表。"""
|
||||
def set_external_available_plugins(self, plugin_versions: Dict[str, str]) -> None:
|
||||
"""设置当前 Runner 启动/重载时可视为已满足的外部依赖版本映射。
|
||||
|
||||
normalized_plugin_ids = {
|
||||
str(plugin_id or "").strip()
|
||||
for plugin_id in plugin_ids
|
||||
if str(plugin_id or "").strip()
|
||||
Args:
|
||||
plugin_versions: 外部插件版本映射,键为插件 ID,值为插件版本。
|
||||
"""
|
||||
self._external_available_plugins = {
|
||||
str(plugin_id or "").strip(): str(plugin_version or "").strip()
|
||||
for plugin_id, plugin_version in plugin_versions.items()
|
||||
if str(plugin_id or "").strip() and str(plugin_version or "").strip()
|
||||
}
|
||||
self._external_available_plugin_ids = sorted(normalized_plugin_ids)
|
||||
|
||||
def get_loaded_plugin_ids(self) -> List[str]:
|
||||
"""返回当前 Supervisor 已注册的插件 ID 列表。"""
|
||||
|
||||
return sorted(self._registered_plugins.keys())
|
||||
|
||||
def get_loaded_plugin_versions(self) -> Dict[str, str]:
|
||||
"""返回当前 Supervisor 已注册插件的版本映射。
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: 已注册插件版本映射,键为插件 ID,值为插件版本。
|
||||
"""
|
||||
return {
|
||||
plugin_id: registration.plugin_version
|
||||
for plugin_id, registration in self._registered_plugins.items()
|
||||
}
|
||||
|
||||
async def dispatch_event(
|
||||
self,
|
||||
event_type: str,
|
||||
@@ -373,14 +386,14 @@ class PluginRunnerSupervisor:
|
||||
self,
|
||||
plugin_id: str,
|
||||
reason: str = "manual",
|
||||
external_available_plugins: Optional[List[str]] = None,
|
||||
external_available_plugins: Optional[Dict[str, str]] = None,
|
||||
) -> bool:
|
||||
"""按插件 ID 触发精确重载。
|
||||
|
||||
Args:
|
||||
plugin_id: 目标插件 ID。
|
||||
reason: 重载原因。
|
||||
external_available_plugins: 视为已满足的外部依赖插件 ID 列表。
|
||||
external_available_plugins: 视为已满足的外部依赖插件版本映射。
|
||||
|
||||
Returns:
|
||||
bool: 是否重载成功。
|
||||
@@ -392,7 +405,7 @@ class PluginRunnerSupervisor:
|
||||
payload={
|
||||
"plugin_id": plugin_id,
|
||||
"reason": reason,
|
||||
"external_available_plugins": external_available_plugins or self._external_available_plugin_ids,
|
||||
"external_available_plugins": external_available_plugins or self._external_available_plugins,
|
||||
},
|
||||
timeout_ms=max(int(self._runner_spawn_timeout * 1000), 10000),
|
||||
)
|
||||
@@ -409,14 +422,14 @@ class PluginRunnerSupervisor:
|
||||
self,
|
||||
plugin_ids: Optional[List[str]] = None,
|
||||
reason: str = "manual",
|
||||
external_available_plugins: Optional[List[str]] = None,
|
||||
external_available_plugins: Optional[Dict[str, str]] = None,
|
||||
) -> bool:
|
||||
"""批量重载插件。
|
||||
|
||||
Args:
|
||||
plugin_ids: 目标插件 ID 列表;为空时重载当前已注册的全部插件。
|
||||
reason: 重载原因。
|
||||
external_available_plugins: 视为已满足的外部依赖插件 ID 列表。
|
||||
external_available_plugins: 视为已满足的外部依赖插件版本映射。
|
||||
|
||||
Returns:
|
||||
bool: 是否全部重载成功。
|
||||
@@ -1136,7 +1149,7 @@ class PluginRunnerSupervisor:
|
||||
global_config_snapshot = config_manager.get_global_config().model_dump()
|
||||
global_config_snapshot["model"] = config_manager.get_model_config().model_dump()
|
||||
return {
|
||||
ENV_EXTERNAL_PLUGIN_IDS: json.dumps(self._external_available_plugin_ids, 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,
|
||||
ENV_IPC_ADDRESS: self._transport.get_address(),
|
||||
|
||||
@@ -11,7 +11,6 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import tomlkit
|
||||
|
||||
@@ -26,6 +25,7 @@ from src.plugin_runtime.capabilities import (
|
||||
)
|
||||
from src.plugin_runtime.capabilities.registry import register_capability_impls
|
||||
from src.plugin_runtime.host.message_utils import MessageDict, PluginMessageUtils
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.chat.message_receive.message import SessionMessage
|
||||
@@ -69,6 +69,7 @@ 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._config_reload_callback: Callable[[Sequence[str]], Awaitable[None]] = self._handle_main_config_reload
|
||||
self._config_reload_callback_registered: bool = False
|
||||
|
||||
@@ -102,46 +103,11 @@ class PluginRuntimeManager(
|
||||
candidate = Path("plugins").resolve()
|
||||
return [candidate] if candidate.is_dir() else []
|
||||
|
||||
@staticmethod
|
||||
def _extract_manifest_dependencies(manifest: Dict[str, Any]) -> List[str]:
|
||||
"""从插件 manifest 中提取规范化后的依赖插件 ID 列表。"""
|
||||
|
||||
dependencies: List[str] = []
|
||||
for dependency in manifest.get("dependencies", []):
|
||||
if isinstance(dependency, str):
|
||||
normalized_dependency = dependency.strip()
|
||||
elif isinstance(dependency, dict):
|
||||
normalized_dependency = str(dependency.get("name", "") or "").strip()
|
||||
else:
|
||||
normalized_dependency = ""
|
||||
|
||||
if normalized_dependency:
|
||||
dependencies.append(normalized_dependency)
|
||||
return dependencies
|
||||
|
||||
@classmethod
|
||||
def _discover_plugin_dependency_map(cls, plugin_dirs: Iterable[Path]) -> Dict[str, List[str]]:
|
||||
"""扫描指定插件目录集合,返回 ``plugin_id -> dependencies`` 映射。"""
|
||||
|
||||
dependency_map: Dict[str, List[str]] = {}
|
||||
for plugin_dir in cls._iter_candidate_plugin_paths(plugin_dirs):
|
||||
manifest_path = plugin_dir / "_manifest.json"
|
||||
entrypoint_path = plugin_dir / "plugin.py"
|
||||
if not manifest_path.is_file() or not entrypoint_path.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
with manifest_path.open("r", encoding="utf-8") as manifest_file:
|
||||
manifest = json.load(manifest_file)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not isinstance(manifest, dict):
|
||||
continue
|
||||
|
||||
plugin_id = str(manifest.get("name", plugin_dir.name) or "").strip() or plugin_dir.name
|
||||
dependency_map[plugin_id] = cls._extract_manifest_dependencies(manifest)
|
||||
return dependency_map
|
||||
validator = ManifestValidator()
|
||||
return validator.build_plugin_dependency_map(plugin_dirs)
|
||||
|
||||
@classmethod
|
||||
def _build_group_start_order(
|
||||
@@ -243,12 +209,12 @@ class PluginRuntimeManager(
|
||||
if supervisor is None:
|
||||
continue
|
||||
|
||||
external_plugin_ids = [
|
||||
plugin_id
|
||||
external_plugin_versions = {
|
||||
plugin_id: plugin_version
|
||||
for started_supervisor in started_supervisors
|
||||
for plugin_id in started_supervisor.get_loaded_plugin_ids()
|
||||
]
|
||||
supervisor.set_external_available_plugin_ids(external_plugin_ids)
|
||||
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)
|
||||
|
||||
@@ -366,23 +332,22 @@ class PluginRuntimeManager(
|
||||
for plugin_id in supervisor.get_loaded_plugin_ids()
|
||||
}
|
||||
|
||||
def _build_external_available_plugins_for_supervisor(self, target_supervisor: "PluginSupervisor") -> List[str]:
|
||||
"""收集某个 Supervisor 可用的外部插件 ID 列表。"""
|
||||
def _build_external_available_plugins_for_supervisor(self, target_supervisor: "PluginSupervisor") -> Dict[str, str]:
|
||||
"""收集某个 Supervisor 可用的外部插件版本映射。"""
|
||||
|
||||
external_plugin_ids: Set[str] = set()
|
||||
external_plugin_versions: Dict[str, str] = {}
|
||||
for supervisor in self.supervisors:
|
||||
if supervisor is target_supervisor:
|
||||
continue
|
||||
external_plugin_ids.update(supervisor.get_loaded_plugin_ids())
|
||||
return sorted(external_plugin_ids)
|
||||
external_plugin_versions.update(supervisor.get_loaded_plugin_versions())
|
||||
return external_plugin_versions
|
||||
|
||||
def _find_supervisor_by_plugin_directory(self, plugin_id: str) -> Optional["PluginSupervisor"]:
|
||||
"""根据插件目录推断应负责该插件重载的 Supervisor。"""
|
||||
|
||||
for supervisor in self.supervisors:
|
||||
for plugin_dir in supervisor._plugin_dirs:
|
||||
if (Path(plugin_dir) / plugin_id).is_dir():
|
||||
return supervisor
|
||||
if self._get_plugin_path_for_supervisor(supervisor, plugin_id) is not None:
|
||||
return supervisor
|
||||
return None
|
||||
|
||||
def _warn_skipped_cross_supervisor_reload(
|
||||
@@ -740,30 +705,13 @@ class PluginRuntimeManager(
|
||||
external_available_plugins=self._build_external_available_plugins_for_supervisor(supervisor),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _find_duplicate_plugin_ids(plugin_dirs: List[Path]) -> Dict[str, List[Path]]:
|
||||
@classmethod
|
||||
def _find_duplicate_plugin_ids(cls, plugin_dirs: List[Path]) -> Dict[str, List[Path]]:
|
||||
"""扫描插件目录,找出被多个目录重复声明的插件 ID。"""
|
||||
plugin_locations: Dict[str, List[Path]] = {}
|
||||
for base_dir in plugin_dirs:
|
||||
if not base_dir.is_dir():
|
||||
continue
|
||||
for entry in base_dir.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
manifest_path = entry / "_manifest.json"
|
||||
plugin_path = entry / "plugin.py"
|
||||
if not manifest_path.exists() or not plugin_path.exists():
|
||||
continue
|
||||
|
||||
plugin_id = entry.name
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as manifest_file:
|
||||
manifest = json.load(manifest_file)
|
||||
plugin_id = str(manifest.get("name", entry.name)).strip() or entry.name
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
plugin_locations.setdefault(plugin_id, []).append(entry)
|
||||
validator = ManifestValidator()
|
||||
for plugin_path, manifest in validator.iter_plugin_manifests(plugin_dirs):
|
||||
plugin_locations.setdefault(manifest.id, []).append(plugin_path)
|
||||
|
||||
return {
|
||||
plugin_id: sorted(dict.fromkeys(paths), key=lambda p: str(p))
|
||||
@@ -831,8 +779,7 @@ class PluginRuntimeManager(
|
||||
if entry.is_dir():
|
||||
yield entry.resolve()
|
||||
|
||||
@staticmethod
|
||||
def _read_plugin_id_from_plugin_path(plugin_path: Path) -> Optional[str]:
|
||||
def _read_plugin_id_from_plugin_path(self, plugin_path: Path) -> Optional[str]:
|
||||
"""从单个插件目录中读取 manifest 声明的插件 ID。
|
||||
|
||||
Args:
|
||||
@@ -841,22 +788,7 @@ class PluginRuntimeManager(
|
||||
Returns:
|
||||
Optional[str]: 解析成功时返回插件 ID,否则返回 ``None``。
|
||||
"""
|
||||
manifest_path = plugin_path / "_manifest.json"
|
||||
entrypoint_path = plugin_path / "plugin.py"
|
||||
if not manifest_path.is_file() or not entrypoint_path.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as manifest_file:
|
||||
manifest = json.load(manifest_file)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not isinstance(manifest, dict):
|
||||
return None
|
||||
|
||||
plugin_id = str(manifest.get("name", plugin_path.name)).strip() or plugin_path.name
|
||||
return plugin_id or None
|
||||
return self._manifest_validator.read_plugin_id_from_plugin_path(plugin_path)
|
||||
|
||||
def _iter_discovered_plugin_paths(self, plugin_dirs: Iterable[Path]) -> Iterable[Tuple[str, Path]]:
|
||||
"""迭代目录中可解析到的插件 ID 与实际目录路径。
|
||||
|
||||
@@ -282,8 +282,11 @@ class ReloadPluginPayload(BaseModel):
|
||||
"""目标插件 ID"""
|
||||
reason: str = Field(default="manual", description="重载原因")
|
||||
"""重载原因"""
|
||||
external_available_plugins: List[str] = Field(default_factory=list, description="可视为已满足的外部依赖插件 ID")
|
||||
"""可视为已满足的外部依赖插件 ID"""
|
||||
external_available_plugins: Dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="可视为已满足的外部依赖插件版本映射",
|
||||
)
|
||||
"""可视为已满足的外部依赖插件版本映射"""
|
||||
|
||||
|
||||
class ReloadPluginResultPayload(BaseModel):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,16 +13,16 @@ from typing import Any, Dict, Iterator, List, Optional, Set, Tuple
|
||||
import contextlib
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from src.common.logger import get_logger
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator, PluginManifest
|
||||
|
||||
logger = get_logger("plugin_runtime.runner.plugin_loader")
|
||||
|
||||
PluginCandidate = Tuple[Path, Dict[str, Any], Path]
|
||||
PluginCandidate = Tuple[Path, PluginManifest, Path]
|
||||
|
||||
|
||||
class PluginMeta:
|
||||
@@ -34,7 +34,7 @@ class PluginMeta:
|
||||
plugin_dir: str,
|
||||
module_name: str,
|
||||
plugin_instance: Any,
|
||||
manifest: Dict[str, Any],
|
||||
manifest: PluginManifest,
|
||||
) -> None:
|
||||
"""初始化插件元数据。
|
||||
|
||||
@@ -43,36 +43,16 @@ class PluginMeta:
|
||||
plugin_dir: 插件目录绝对路径。
|
||||
module_name: 插件入口模块名。
|
||||
plugin_instance: 插件实例对象。
|
||||
manifest: 解析后的 manifest 内容。
|
||||
manifest: 解析后的强类型 Manifest。
|
||||
"""
|
||||
self.plugin_id = plugin_id
|
||||
self.plugin_dir = plugin_dir
|
||||
self.module_name = module_name
|
||||
self.instance = plugin_instance
|
||||
self.manifest = manifest
|
||||
self.version = manifest.get("version", "1.0.0")
|
||||
self.capabilities_required = manifest.get("capabilities", [])
|
||||
self.dependencies: List[str] = self._extract_dependencies(manifest)
|
||||
|
||||
@staticmethod
|
||||
def _extract_dependencies(manifest: Dict[str, Any]) -> List[str]:
|
||||
"""从 manifest 中提取依赖列表。
|
||||
|
||||
Args:
|
||||
manifest: 插件 manifest。
|
||||
|
||||
Returns:
|
||||
List[str]: 规范化后的依赖插件 ID 列表。
|
||||
"""
|
||||
raw = manifest.get("dependencies", [])
|
||||
result: List[str] = []
|
||||
for dep in raw:
|
||||
if isinstance(dep, str):
|
||||
result.append(dep.strip())
|
||||
elif isinstance(dep, dict):
|
||||
if name := str(dep.get("name", "")).strip():
|
||||
result.append(name)
|
||||
return result
|
||||
self.version = manifest.version
|
||||
self.capabilities_required = list(manifest.capabilities)
|
||||
self.dependencies: List[str] = list(manifest.plugin_dependency_ids)
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
@@ -98,13 +78,13 @@ class PluginLoader:
|
||||
def discover_and_load(
|
||||
self,
|
||||
plugin_dirs: List[str],
|
||||
extra_available: Optional[Set[str]] = None,
|
||||
extra_available: Optional[Dict[str, str]] = None,
|
||||
) -> List[PluginMeta]:
|
||||
"""扫描多个目录并加载所有插件。
|
||||
|
||||
Args:
|
||||
plugin_dirs: 插件目录列表。
|
||||
extra_available: 额外视为已满足的外部依赖插件 ID 集合。
|
||||
extra_available: 额外视为已满足的外部依赖插件版本映射。
|
||||
|
||||
Returns:
|
||||
List[PluginMeta]: 成功加载的插件元数据列表,按依赖顺序排列。
|
||||
@@ -164,26 +144,17 @@ class PluginLoader:
|
||||
|
||||
def _discover_single_candidate(self, plugin_dir: Path) -> Optional[Tuple[str, PluginCandidate]]:
|
||||
"""发现并校验单个插件目录。"""
|
||||
manifest_path = plugin_dir / "_manifest.json"
|
||||
plugin_path = plugin_dir / "plugin.py"
|
||||
|
||||
if not manifest_path.exists() or not plugin_path.exists():
|
||||
if not plugin_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with manifest_path.open("r", encoding="utf-8") as manifest_file:
|
||||
manifest: Dict[str, Any] = json.load(manifest_file)
|
||||
except Exception as e:
|
||||
self._failed_plugins[plugin_dir.name] = f"manifest 解析失败: {e}"
|
||||
logger.error(f"插件 {plugin_dir.name} manifest 解析失败: {e}")
|
||||
return None
|
||||
|
||||
if not self._manifest_validator.validate(manifest):
|
||||
manifest = self._manifest_validator.load_from_plugin_path(plugin_dir)
|
||||
if manifest is None:
|
||||
errors = "; ".join(self._manifest_validator.errors)
|
||||
self._failed_plugins[plugin_dir.name] = f"manifest 校验失败: {errors}"
|
||||
return None
|
||||
|
||||
plugin_id = str(manifest.get("name", plugin_dir.name)).strip() or plugin_dir.name
|
||||
plugin_id = manifest.id
|
||||
return plugin_id, (plugin_dir, manifest, plugin_path)
|
||||
|
||||
def _record_duplicate_candidates(self, duplicate_candidates: Dict[str, List[Path]]) -> None:
|
||||
@@ -253,7 +224,7 @@ class PluginLoader:
|
||||
"""
|
||||
removed_modules: List[str] = []
|
||||
plugin_path = Path(plugin_dir).resolve()
|
||||
synthetic_module_name = f"_maibot_plugin_{plugin_id}"
|
||||
synthetic_module_name = self._build_safe_module_name(plugin_id)
|
||||
|
||||
for module_name, module in list(sys.modules.items()):
|
||||
if module_name == synthetic_module_name:
|
||||
@@ -277,6 +248,21 @@ class PluginLoader:
|
||||
importlib.invalidate_caches()
|
||||
return removed_modules
|
||||
|
||||
@staticmethod
|
||||
def _build_safe_module_name(plugin_id: str) -> str:
|
||||
"""将插件 ID 转换为可用于动态导入的安全模块名。
|
||||
|
||||
Args:
|
||||
plugin_id: 原始插件 ID。
|
||||
|
||||
Returns:
|
||||
str: 仅包含字母、数字和下划线的合成模块名。
|
||||
"""
|
||||
normalized_plugin_id = re.sub(r"[^0-9A-Za-z_]", "_", str(plugin_id or "").strip())
|
||||
if normalized_plugin_id and normalized_plugin_id[0].isdigit():
|
||||
normalized_plugin_id = f"_{normalized_plugin_id}"
|
||||
return f"_maibot_plugin_{normalized_plugin_id or 'plugin'}"
|
||||
|
||||
def list_plugins(self) -> List[str]:
|
||||
"""列出所有已加载的插件 ID"""
|
||||
return list(self._loaded_plugins.keys())
|
||||
@@ -286,18 +272,27 @@ class PluginLoader:
|
||||
"""返回当前记录的失败插件原因映射。"""
|
||||
return dict(self._failed_plugins)
|
||||
|
||||
@property
|
||||
def manifest_validator(self) -> ManifestValidator:
|
||||
"""返回当前加载器持有的 Manifest 校验器。
|
||||
|
||||
Returns:
|
||||
ManifestValidator: 当前使用的 Manifest 校验器实例。
|
||||
"""
|
||||
return self._manifest_validator
|
||||
|
||||
# ──── 依赖解析 ────────────────────────────────────────────
|
||||
|
||||
def resolve_dependencies(
|
||||
self,
|
||||
candidates: Dict[str, PluginCandidate],
|
||||
extra_available: Optional[Set[str]] = None,
|
||||
extra_available: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[List[str], Dict[str, str]]:
|
||||
"""解析候选插件的依赖顺序。
|
||||
|
||||
Args:
|
||||
candidates: 待加载的候选插件集合。
|
||||
extra_available: 视为已满足的外部依赖插件 ID 集合。
|
||||
extra_available: 视为已满足的外部依赖插件版本映射。
|
||||
|
||||
Returns:
|
||||
Tuple[List[str], Dict[str, str]]: 可加载顺序和失败原因映射。
|
||||
@@ -320,36 +315,71 @@ class PluginLoader:
|
||||
def _resolve_dependencies(
|
||||
self,
|
||||
candidates: Dict[str, PluginCandidate],
|
||||
extra_available: Optional[Set[str]] = None,
|
||||
extra_available: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[List[str], Dict[str, str]]:
|
||||
"""拓扑排序解析加载顺序,返回 (有序列表, 失败项 {id: reason})。"""
|
||||
available = set(candidates.keys())
|
||||
satisfied_dependencies = set(extra_available or set())
|
||||
satisfied_dependencies = {
|
||||
str(plugin_id or "").strip(): str(plugin_version or "").strip()
|
||||
for plugin_id, plugin_version in (extra_available or {}).items()
|
||||
if str(plugin_id or "").strip() and str(plugin_version or "").strip()
|
||||
}
|
||||
dep_graph: Dict[str, Set[str]] = {}
|
||||
failed: Dict[str, str] = {}
|
||||
|
||||
for pid, (_, manifest, _) in candidates.items():
|
||||
raw_deps = manifest.get("dependencies", [])
|
||||
resolved: Set[str] = set()
|
||||
missing: List[str] = []
|
||||
for dep in raw_deps:
|
||||
dep_name = dep if isinstance(dep, str) else str(dep.get("name", ""))
|
||||
dep_name = dep_name.strip()
|
||||
if not dep_name or dep_name == pid:
|
||||
missing_or_incompatible: List[str] = []
|
||||
|
||||
for dependency in manifest.plugin_dependencies:
|
||||
dependency_id = dependency.id
|
||||
if dependency_id in available:
|
||||
dependency_manifest = candidates[dependency_id][1]
|
||||
if not self._manifest_validator.is_plugin_dependency_satisfied(
|
||||
dependency,
|
||||
dependency_manifest.version,
|
||||
):
|
||||
missing_or_incompatible.append(
|
||||
f"{dependency_id} (需要 {dependency.version_spec},当前 {dependency_manifest.version})"
|
||||
)
|
||||
continue
|
||||
resolved.add(dependency_id)
|
||||
continue
|
||||
if dep_name in available:
|
||||
resolved.add(dep_name)
|
||||
elif dep_name in satisfied_dependencies:
|
||||
|
||||
external_dependency_version = satisfied_dependencies.get(dependency_id)
|
||||
if external_dependency_version is None:
|
||||
missing_or_incompatible.append(f"{dependency_id} (未找到依赖插件)")
|
||||
continue
|
||||
else:
|
||||
missing.append(dep_name)
|
||||
if missing:
|
||||
failed[pid] = f"缺少依赖: {', '.join(missing)}"
|
||||
|
||||
if not self._manifest_validator.is_plugin_dependency_satisfied(
|
||||
dependency,
|
||||
external_dependency_version,
|
||||
):
|
||||
missing_or_incompatible.append(
|
||||
f"{dependency_id} (需要 {dependency.version_spec},当前 {external_dependency_version})"
|
||||
)
|
||||
|
||||
if missing_or_incompatible:
|
||||
failed[pid] = f"依赖未满足: {', '.join(missing_or_incompatible)}"
|
||||
dep_graph[pid] = resolved
|
||||
|
||||
# 移除失败项
|
||||
for pid in failed:
|
||||
dep_graph.pop(pid, None)
|
||||
# 迭代传播“依赖自身加载失败”到上游依赖方,避免误报为循环依赖
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
failed_plugin_ids = set(failed)
|
||||
for pid, dependencies in list(dep_graph.items()):
|
||||
if pid in failed:
|
||||
dep_graph.pop(pid, None)
|
||||
continue
|
||||
|
||||
failed_dependencies = sorted(dependency for dependency in dependencies if dependency in failed_plugin_ids)
|
||||
if not failed_dependencies:
|
||||
continue
|
||||
|
||||
failed[pid] = f"依赖未满足: {', '.join(f'{dependency} (依赖插件加载失败)' for dependency in failed_dependencies)}"
|
||||
dep_graph.pop(pid, None)
|
||||
changed = True
|
||||
|
||||
# Kahn 拓扑排序
|
||||
indegree = {pid: len(deps) for pid, deps in dep_graph.items()}
|
||||
@@ -382,7 +412,7 @@ class PluginLoader:
|
||||
self,
|
||||
plugin_id: str,
|
||||
plugin_dir: Path,
|
||||
manifest: Dict[str, Any],
|
||||
manifest: PluginManifest,
|
||||
plugin_path: Path,
|
||||
) -> Optional[PluginMeta]:
|
||||
"""加载单个插件"""
|
||||
@@ -390,7 +420,7 @@ class PluginLoader:
|
||||
self._ensure_compat_hook()
|
||||
|
||||
# 动态导入插件模块
|
||||
module_name = f"_maibot_plugin_{plugin_id}"
|
||||
module_name = self._build_safe_module_name(plugin_id)
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(plugin_path))
|
||||
if spec is None or spec.loader is None:
|
||||
logger.error(f"无法创建模块 spec: {plugin_path}")
|
||||
@@ -409,7 +439,7 @@ class PluginLoader:
|
||||
if create_plugin is not None:
|
||||
instance = create_plugin()
|
||||
self._validate_sdk_plugin_contract(plugin_id, instance)
|
||||
logger.info(f"插件 {plugin_id} v{manifest.get('version', '?')} 加载成功")
|
||||
logger.info(f"插件 {plugin_id} v{manifest.version} 加载成功")
|
||||
return PluginMeta(
|
||||
plugin_id=plugin_id,
|
||||
plugin_dir=str(plugin_dir),
|
||||
@@ -422,7 +452,7 @@ class PluginLoader:
|
||||
instance = self._try_load_legacy_plugin(module, plugin_id)
|
||||
if instance is not None:
|
||||
logger.info(
|
||||
f"插件 {plugin_id} v{manifest.get('version', '?')} 通过旧版兼容层加载成功(请尽快迁移到 maibot_sdk)"
|
||||
f"插件 {plugin_id} v{manifest.version} 通过旧版兼容层加载成功(请尽快迁移到 maibot_sdk)"
|
||||
)
|
||||
return PluginMeta(
|
||||
plugin_id=plugin_id,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, Set, cast
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
@@ -47,7 +47,7 @@ from src.plugin_runtime.protocol.envelope import (
|
||||
)
|
||||
from src.plugin_runtime.protocol.errors import ErrorCode
|
||||
from src.plugin_runtime.runner.log_handler import RunnerIPCLogHandler
|
||||
from src.plugin_runtime.runner.plugin_loader import PluginLoader, PluginMeta
|
||||
from src.plugin_runtime.runner.plugin_loader import PluginCandidate, PluginLoader, PluginMeta
|
||||
from src.plugin_runtime.runner.rpc_client import RPCClient
|
||||
|
||||
logger = get_logger("plugin_runtime.runner.main")
|
||||
@@ -119,7 +119,7 @@ class PluginRunner:
|
||||
host_address: str,
|
||||
session_token: str,
|
||||
plugin_dirs: List[str],
|
||||
external_available_plugin_ids: Optional[List[str]] = None,
|
||||
external_available_plugins: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
"""初始化 Runner。
|
||||
|
||||
@@ -127,15 +127,15 @@ class PluginRunner:
|
||||
host_address: Host 的 IPC 地址。
|
||||
session_token: 握手用会话令牌。
|
||||
plugin_dirs: 当前 Runner 负责扫描的插件目录列表。
|
||||
external_available_plugin_ids: 视为已满足的外部依赖插件 ID 列表。
|
||||
external_available_plugins: 视为已满足的外部依赖插件版本映射。
|
||||
"""
|
||||
self._host_address: str = host_address
|
||||
self._session_token: str = session_token
|
||||
self._plugin_dirs: List[str] = plugin_dirs
|
||||
self._external_available_plugin_ids: Set[str] = {
|
||||
str(plugin_id or "").strip()
|
||||
for plugin_id in (external_available_plugin_ids or [])
|
||||
if str(plugin_id or "").strip()
|
||||
self._external_available_plugins: Dict[str, str] = {
|
||||
str(plugin_id or "").strip(): str(plugin_version or "").strip()
|
||||
for plugin_id, plugin_version in (external_available_plugins or {}).items()
|
||||
if str(plugin_id or "").strip() and str(plugin_version or "").strip()
|
||||
}
|
||||
|
||||
self._rpc_client: RPCClient = RPCClient(host_address, session_token)
|
||||
@@ -166,7 +166,7 @@ class PluginRunner:
|
||||
# 3. 加载插件
|
||||
plugins = self._loader.discover_and_load(
|
||||
self._plugin_dirs,
|
||||
extra_available=self._external_available_plugin_ids,
|
||||
extra_available=self._external_available_plugins,
|
||||
)
|
||||
logger.info(f"已加载 {len(plugins)} 个插件")
|
||||
|
||||
@@ -611,14 +611,14 @@ class PluginRunner:
|
||||
self,
|
||||
plugin_id: str,
|
||||
reason: str,
|
||||
external_available_plugins: Optional[Set[str]] = None,
|
||||
external_available_plugins: Optional[Dict[str, str]] = None,
|
||||
) -> ReloadPluginResultPayload:
|
||||
"""按插件 ID 在 Runner 进程内执行精确重载。
|
||||
|
||||
Args:
|
||||
plugin_id: 目标插件 ID。
|
||||
reason: 重载原因。
|
||||
external_available_plugins: 视为已满足的外部依赖插件 ID 集合。
|
||||
external_available_plugins: 视为已满足的外部依赖插件版本映射。
|
||||
|
||||
Returns:
|
||||
ReloadPluginResultPayload: 结构化重载结果。
|
||||
@@ -626,9 +626,9 @@ class PluginRunner:
|
||||
candidates, duplicate_candidates = self._loader.discover_candidates(self._plugin_dirs)
|
||||
failed_plugins: Dict[str, str] = {}
|
||||
normalized_external_available = {
|
||||
str(candidate_plugin_id or "").strip()
|
||||
for candidate_plugin_id in (external_available_plugins or set())
|
||||
if str(candidate_plugin_id or "").strip()
|
||||
str(candidate_plugin_id or "").strip(): str(candidate_plugin_version or "").strip()
|
||||
for candidate_plugin_id, candidate_plugin_version in (external_available_plugins or {}).items()
|
||||
if str(candidate_plugin_id or "").strip() and str(candidate_plugin_version or "").strip()
|
||||
}
|
||||
|
||||
if plugin_id in duplicate_candidates:
|
||||
@@ -668,7 +668,7 @@ class PluginRunner:
|
||||
self._loader.purge_plugin_modules(unload_plugin_id, meta.plugin_dir)
|
||||
unloaded_plugins.append(unload_plugin_id)
|
||||
|
||||
reload_candidates: Dict[str, Tuple[Path, Dict[str, Any], Path]] = {}
|
||||
reload_candidates: Dict[str, PluginCandidate] = {}
|
||||
for target_plugin_id in target_plugin_ids:
|
||||
candidate = candidates.get(target_plugin_id)
|
||||
if candidate is None:
|
||||
@@ -678,11 +678,25 @@ class PluginRunner:
|
||||
|
||||
load_order, dependency_failures = self._loader.resolve_dependencies(
|
||||
reload_candidates,
|
||||
extra_available=retained_plugin_ids | normalized_external_available,
|
||||
extra_available={
|
||||
**normalized_external_available,
|
||||
**{
|
||||
retained_plugin_id: retained_meta.version
|
||||
for retained_plugin_id in retained_plugin_ids
|
||||
if (retained_meta := self._loader.get_plugin(retained_plugin_id)) is not None
|
||||
},
|
||||
},
|
||||
)
|
||||
failed_plugins.update(dependency_failures)
|
||||
|
||||
available_plugins = set(retained_plugin_ids) | normalized_external_available
|
||||
available_plugins = {
|
||||
**normalized_external_available,
|
||||
**{
|
||||
retained_plugin_id: retained_meta.version
|
||||
for retained_plugin_id in retained_plugin_ids
|
||||
if (retained_meta := self._loader.get_plugin(retained_plugin_id)) is not None
|
||||
},
|
||||
}
|
||||
reloaded_plugins: List[str] = []
|
||||
|
||||
for load_plugin_id in load_order:
|
||||
@@ -694,10 +708,12 @@ class PluginRunner:
|
||||
continue
|
||||
|
||||
_, manifest, _ = candidate
|
||||
dependencies = PluginMeta._extract_dependencies(manifest)
|
||||
missing_dependencies = [dependency for dependency in dependencies if dependency not in available_plugins]
|
||||
if missing_dependencies:
|
||||
failed_plugins[load_plugin_id] = f"依赖未满足: {', '.join(missing_dependencies)}"
|
||||
unsatisfied_dependencies = self._loader.manifest_validator.get_unsatisfied_plugin_dependencies(
|
||||
manifest,
|
||||
available_plugin_versions=available_plugins,
|
||||
)
|
||||
if unsatisfied_dependencies:
|
||||
failed_plugins[load_plugin_id] = f"依赖未满足: {', '.join(unsatisfied_dependencies)}"
|
||||
continue
|
||||
|
||||
meta = self._loader.load_candidate(load_plugin_id, candidate)
|
||||
@@ -710,7 +726,7 @@ class PluginRunner:
|
||||
failed_plugins[load_plugin_id] = "插件初始化失败"
|
||||
continue
|
||||
|
||||
available_plugins.add(load_plugin_id)
|
||||
available_plugins[load_plugin_id] = meta.version
|
||||
reloaded_plugins.append(load_plugin_id)
|
||||
|
||||
if failed_plugins:
|
||||
@@ -1079,7 +1095,7 @@ class PluginRunner:
|
||||
result = await self._reload_plugin_by_id(
|
||||
payload.plugin_id,
|
||||
payload.reason,
|
||||
external_available_plugins=set(payload.external_available_plugins),
|
||||
external_available_plugins=dict(payload.external_available_plugins),
|
||||
)
|
||||
return envelope.make_response(payload=result.model_dump())
|
||||
|
||||
@@ -1185,13 +1201,13 @@ async def _async_main() -> None:
|
||||
|
||||
plugin_dirs = [d for d in plugin_dirs_str.split(os.pathsep) if d]
|
||||
try:
|
||||
external_plugin_ids = json.loads(external_plugin_ids_raw) if external_plugin_ids_raw else []
|
||||
external_plugin_ids = json.loads(external_plugin_ids_raw) if external_plugin_ids_raw else {}
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("解析外部依赖插件列表失败,已回退为空列表")
|
||||
external_plugin_ids = []
|
||||
if not isinstance(external_plugin_ids, list):
|
||||
logger.warning("外部依赖插件列表格式非法,已回退为空列表")
|
||||
external_plugin_ids = []
|
||||
logger.warning("解析外部依赖插件版本映射失败,已回退为空映射")
|
||||
external_plugin_ids = {}
|
||||
if not isinstance(external_plugin_ids, dict):
|
||||
logger.warning("外部依赖插件版本映射格式非法,已回退为空映射")
|
||||
external_plugin_ids = {}
|
||||
|
||||
# sys.path 隔离: 只保留标准库、SDK 包、插件目录
|
||||
_isolate_sys_path(plugin_dirs)
|
||||
@@ -1200,7 +1216,10 @@ async def _async_main() -> None:
|
||||
host_address,
|
||||
session_token,
|
||||
plugin_dirs,
|
||||
external_available_plugin_ids=[str(plugin_id) for plugin_id in external_plugin_ids],
|
||||
external_available_plugins={
|
||||
str(plugin_id): str(plugin_version)
|
||||
for plugin_id, plugin_version in external_plugin_ids.items()
|
||||
},
|
||||
)
|
||||
|
||||
# 注册信号处理
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"name": "Emoji插件 (Emoji Actions)",
|
||||
"manifest_version": 2,
|
||||
"version": "2.0.0",
|
||||
"description": "可以发送和管理Emoji",
|
||||
"name": "Emoji插件 (Emoji Actions)",
|
||||
"description": "可以发送和管理 Emoji",
|
||||
"author": {
|
||||
"name": "SengokuCola",
|
||||
"url": "https://github.com/MaiM-with-u"
|
||||
},
|
||||
"license": "GPL-v3.0-or-later",
|
||||
"urls": {
|
||||
"repository": "https://github.com/MaiM-with-u/maibot",
|
||||
"homepage": "https://github.com/MaiM-with-u/maibot",
|
||||
"documentation": "https://github.com/MaiM-with-u/maibot",
|
||||
"issues": "https://github.com/MaiM-with-u/maibot/issues"
|
||||
},
|
||||
"host_application": {
|
||||
"min_version": "1.0.0"
|
||||
"min_version": "1.0.0",
|
||||
"max_version": "1.0.0"
|
||||
},
|
||||
"homepage_url": "https://github.com/MaiM-with-u/maibot",
|
||||
"repository_url": "https://github.com/MaiM-with-u/maibot",
|
||||
"keywords": ["emoji", "action", "built-in"],
|
||||
"categories": ["Emoji"],
|
||||
"default_locale": "zh-CN",
|
||||
"plugin_info": {
|
||||
"is_built_in": true,
|
||||
"plugin_type": "action_provider",
|
||||
"components": [
|
||||
{
|
||||
"type": "action",
|
||||
"name": "emoji",
|
||||
"description": "发送表情包辅助表达情绪"
|
||||
}
|
||||
]
|
||||
"sdk": {
|
||||
"min_version": "2.0.0",
|
||||
"max_version": "2.99.99"
|
||||
},
|
||||
"dependencies": [],
|
||||
"capabilities": [
|
||||
"emoji.get_random",
|
||||
"message.get_recent",
|
||||
@@ -34,5 +30,12 @@
|
||||
"llm.generate",
|
||||
"send.emoji",
|
||||
"config.get"
|
||||
]
|
||||
],
|
||||
"i18n": {
|
||||
"default_locale": "zh-CN",
|
||||
"supported_locales": [
|
||||
"zh-CN"
|
||||
]
|
||||
},
|
||||
"id": "builtin.emoji-plugin"
|
||||
}
|
||||
|
||||
@@ -1,51 +1,46 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"name": "插件和组件管理 (Plugin and Component Management)",
|
||||
"manifest_version": 2,
|
||||
"version": "2.0.0",
|
||||
"description": "通过系统API管理插件和组件的生命周期,包括加载、卸载、启用和禁用等操作。",
|
||||
"name": "插件和组件管理 (Plugin and Component Management)",
|
||||
"description": "通过系统 API 管理插件和组件的生命周期,包括加载、卸载、启用和禁用等操作。",
|
||||
"author": {
|
||||
"name": "MaiBot团队",
|
||||
"url": "https://github.com/MaiM-with-u"
|
||||
},
|
||||
"license": "GPL-v3.0-or-later",
|
||||
"host_application": {
|
||||
"min_version": "1.0.0"
|
||||
"urls": {
|
||||
"repository": "https://github.com/MaiM-with-u/maibot",
|
||||
"homepage": "https://github.com/MaiM-with-u/maibot",
|
||||
"documentation": "https://github.com/MaiM-with-u/maibot",
|
||||
"issues": "https://github.com/MaiM-with-u/maibot/issues"
|
||||
},
|
||||
"homepage_url": "https://github.com/MaiM-with-u/maibot",
|
||||
"repository_url": "https://github.com/MaiM-with-u/maibot",
|
||||
"keywords": [
|
||||
"plugins",
|
||||
"components",
|
||||
"management",
|
||||
"built-in"
|
||||
"host_application": {
|
||||
"min_version": "1.0.0",
|
||||
"max_version": "1.0.0"
|
||||
},
|
||||
"sdk": {
|
||||
"min_version": "2.0.0",
|
||||
"max_version": "2.99.99"
|
||||
},
|
||||
"dependencies": [],
|
||||
"capabilities": [
|
||||
"component.get_all_plugins",
|
||||
"component.list_loaded_plugins",
|
||||
"component.list_registered_plugins",
|
||||
"component.enable",
|
||||
"component.disable",
|
||||
"component.load_plugin",
|
||||
"component.unload_plugin",
|
||||
"component.reload_plugin",
|
||||
"send.text",
|
||||
"config.get"
|
||||
],
|
||||
"categories": [
|
||||
"Core System",
|
||||
"Plugin Management"
|
||||
],
|
||||
"default_locale": "zh-CN",
|
||||
"locales_path": "_locales",
|
||||
"plugin_info": {
|
||||
"is_built_in": true,
|
||||
"plugin_type": "plugin_management",
|
||||
"capabilities": [
|
||||
"component.get_all_plugins",
|
||||
"component.list_loaded_plugins",
|
||||
"component.list_registered_plugins",
|
||||
"component.enable",
|
||||
"component.disable",
|
||||
"component.load_plugin",
|
||||
"component.unload_plugin",
|
||||
"component.reload_plugin",
|
||||
"send.text",
|
||||
"config.get"
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "management",
|
||||
"description": "管理插件和组件的生命周期,包括加载、卸载、启用和禁用等操作。"
|
||||
}
|
||||
"i18n": {
|
||||
"default_locale": "zh-CN",
|
||||
"locales_path": "_locales",
|
||||
"supported_locales": [
|
||||
"zh-CN"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "builtin.plugin-management"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user