feat(plugin): implement caching for plugin agent tools registry

This commit is contained in:
jxxghp
2026-06-19 20:50:35 +08:00
parent 38c3dcc76b
commit 7f1cb40421
3 changed files with 290 additions and 89 deletions

View File

@@ -1,4 +1,4 @@
from typing import List, Callable
from typing import Callable, List, Optional, Type
from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
from app.agent.tools.impl.add_subscribe import AddSubscribeTool
@@ -93,6 +93,86 @@ class MoviePilotToolFactory:
MoviePilot工具工厂
"""
BUILTIN_TOOL_CLASSES: tuple[Type[MoviePilotTool], ...] = (
SearchMediaTool,
SearchPersonTool,
SearchPersonCreditsTool,
RecognizeMediaTool,
ScrapeMetadataTool,
QueryEpisodeScheduleTool,
QueryMediaDetailTool,
AddSubscribeTool,
UpdateSubscribeTool,
SearchSubscribeTool,
SearchTorrentsTool,
GetSearchResultsTool,
SearchWebTool,
RecognizeCaptchaTool,
AddDownloadTasksTool,
QuerySubscribesTool,
QuerySubscribeSharesTool,
QueryPopularSubscribesTool,
QueryBuiltinFilterRulesTool,
QueryCustomFilterRulesTool,
QueryRuleGroupsTool,
AddCustomFilterRuleTool,
UpdateCustomFilterRuleTool,
DeleteCustomFilterRuleTool,
AddRuleGroupTool,
UpdateRuleGroupTool,
DeleteRuleGroupTool,
QuerySubscribeHistoryTool,
DeleteSubscribeTool,
QueryDownloadTasksTool,
DeleteDownloadTasksTool,
DeleteDownloadHistoryTool,
DeleteTransferHistoryTool,
UpdateDownloadTasksTool,
QueryDownloadersTool,
QuerySitesTool,
UpdateSiteTool,
QuerySiteUserdataTool,
TestSiteTool,
UpdateSiteCookieTool,
GetRecommendationsTool,
QueryLibraryExistsTool,
QueryLibraryLatestTool,
QueryDirectorySettingsTool,
ListDirectoryTool,
QueryTransferHistoryTool,
TransferFileTool,
SendMessageTool,
QuerySchedulersTool,
RunSchedulerTool,
QueryWorkflowsTool,
RunWorkflowTool,
QueryPersonasTool,
SwitchPersonaTool,
UpdatePersonaDefinitionTool,
ExecuteCommandTool,
EditFileTool,
WriteFileTool,
ReadFileTool,
BrowseWebpageTool,
QueryInstalledPluginsTool,
QueryMarketPluginsTool,
QueryPluginCapabilitiesTool,
QueryPluginConfigTool,
UpdatePluginConfigTool,
ReloadPluginTool,
QueryPluginDataTool,
InstallPluginTool,
UninstallPluginTool,
RunSlashCommandTool,
ListSlashCommandsTool,
QueryActivityLogTool,
QueryDoctorReportTool,
QueryCustomIdentifiersTool,
UpdateCustomIdentifiersTool,
QuerySystemSettingsTool,
UpdateSystemSettingsTool,
)
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
# 文件系统、命令执行、历史检索或交互确认能力。AskUserChoiceTool 仅在支持按钮
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
@@ -107,7 +187,7 @@ class MoviePilotToolFactory:
)
@staticmethod
def _should_enable_choice_tool(channel: str = None) -> bool:
def _should_enable_choice_tool(channel: Optional[str] = None) -> bool:
if not channel:
return False
try:
@@ -137,8 +217,24 @@ class MoviePilotToolFactory:
if tool_name in available_tool_names
]
@staticmethod
@classmethod
def _get_builtin_tool_classes(
cls, channel: Optional[str] = None
) -> list[Type[MoviePilotTool]]:
"""
返回当前渠道可用的内置工具类清单。
"""
tool_definitions = list(cls.BUILTIN_TOOL_CLASSES)
if cls._should_enable_choice_tool(channel):
tool_definitions.append(AskUserChoiceTool)
tool_definitions.append(SendLocalFileTool)
if AgentCapabilityManager.supports_audio_output():
tool_definitions.append(SendVoiceMessageTool)
return tool_definitions
@classmethod
def create_tools(
cls,
session_id: str,
user_id: str,
channel: str = None,
@@ -152,90 +248,7 @@ class MoviePilotToolFactory:
创建MoviePilot工具列表
"""
tools = []
tool_definitions = [
SearchMediaTool,
SearchPersonTool,
SearchPersonCreditsTool,
RecognizeMediaTool,
ScrapeMetadataTool,
QueryEpisodeScheduleTool,
QueryMediaDetailTool,
AddSubscribeTool,
UpdateSubscribeTool,
SearchSubscribeTool,
SearchTorrentsTool,
GetSearchResultsTool,
SearchWebTool,
RecognizeCaptchaTool,
AddDownloadTasksTool,
QuerySubscribesTool,
QuerySubscribeSharesTool,
QueryPopularSubscribesTool,
QueryBuiltinFilterRulesTool,
QueryCustomFilterRulesTool,
QueryRuleGroupsTool,
AddCustomFilterRuleTool,
UpdateCustomFilterRuleTool,
DeleteCustomFilterRuleTool,
AddRuleGroupTool,
UpdateRuleGroupTool,
DeleteRuleGroupTool,
QuerySubscribeHistoryTool,
DeleteSubscribeTool,
QueryDownloadTasksTool,
DeleteDownloadTasksTool,
DeleteDownloadHistoryTool,
DeleteTransferHistoryTool,
UpdateDownloadTasksTool,
QueryDownloadersTool,
QuerySitesTool,
UpdateSiteTool,
QuerySiteUserdataTool,
TestSiteTool,
UpdateSiteCookieTool,
GetRecommendationsTool,
QueryLibraryExistsTool,
QueryLibraryLatestTool,
QueryDirectorySettingsTool,
ListDirectoryTool,
QueryTransferHistoryTool,
TransferFileTool,
SendMessageTool,
QuerySchedulersTool,
RunSchedulerTool,
QueryWorkflowsTool,
RunWorkflowTool,
QueryPersonasTool,
SwitchPersonaTool,
UpdatePersonaDefinitionTool,
ExecuteCommandTool,
EditFileTool,
WriteFileTool,
ReadFileTool,
BrowseWebpageTool,
QueryInstalledPluginsTool,
QueryMarketPluginsTool,
QueryPluginCapabilitiesTool,
QueryPluginConfigTool,
UpdatePluginConfigTool,
ReloadPluginTool,
QueryPluginDataTool,
InstallPluginTool,
UninstallPluginTool,
RunSlashCommandTool,
ListSlashCommandsTool,
QueryActivityLogTool,
QueryDoctorReportTool,
QueryCustomIdentifiersTool,
UpdateCustomIdentifiersTool,
QuerySystemSettingsTool,
UpdateSystemSettingsTool,
]
if MoviePilotToolFactory._should_enable_choice_tool(channel):
tool_definitions.append(AskUserChoiceTool)
tool_definitions.append(SendLocalFileTool)
if AgentCapabilityManager.supports_audio_output():
tool_definitions.append(SendVoiceMessageTool)
tool_definitions = cls._get_builtin_tool_classes(channel)
# 创建内置工具
for ToolClass in tool_definitions:
tool = ToolClass(session_id=session_id, user_id=user_id)
@@ -282,9 +295,9 @@ class MoviePilotToolFactory:
builtin_tools_count = len(tool_definitions)
if plugin_tools_count > 0:
logger.info(
logger.debug(
f"成功创建 {len(tools)} 个MoviePilot工具内置工具: {builtin_tools_count} 个,插件工具: {plugin_tools_count} 个)"
)
else:
logger.info(f"成功创建 {len(tools)} 个MoviePilot工具")
logger.debug(f"成功创建 {len(tools)} 个MoviePilot工具")
return tools

View File

@@ -55,6 +55,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
self._stop_monitor_event = threading.Event()
# 本地插件同步写入运行目录后的短时忽略窗口
self._recent_local_sync: Dict[str, float] = {}
# 插件智能体工具注册表缓存,插件启停或配置生效时主动失效。
self._plugin_agent_tools_cache: Dict[str, List[Dict[str, Any]]] = {}
self._plugin_agent_tools_cache_lock = threading.Lock()
# 开发者模式监测插件修改
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
self.__start_monitor()
@@ -112,6 +115,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
eventmanager.disable_event_handler(plugin)
except Exception as err:
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
self.clear_plugin_agent_tools_cache()
def init_plugin(self, plugin_id: str, conf: dict):
"""
@@ -131,6 +135,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
else:
# 禁用插件类的事件处理器
eventmanager.disable_event_handler(type(plugin))
self.clear_plugin_agent_tools_cache()
def clear_plugin_agent_tools_cache(self) -> None:
"""
清空插件智能体工具注册表缓存。
"""
with self._plugin_agent_tools_cache_lock:
self._plugin_agent_tools_cache.clear()
def stop(self, pid: Optional[str] = None):
"""
@@ -167,6 +179,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
self._running_plugins = {}
# 清除所有插件模块缓存
self._clear_plugin_modules()
self.clear_plugin_agent_tools_cache()
logger.info("插件停止完成")
@staticmethod
@@ -882,6 +895,21 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
logger.error(f"获取插件 {plugin_id} 动作出错:{str(e)}")
return ret_actions
@staticmethod
def _copy_plugin_agent_tools(
tools_info: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""
复制插件智能体工具注册信息,避免调用方修改缓存内容。
"""
return [
{
**plugin_info,
"tools": list(plugin_info.get("tools", [])),
}
for plugin_info in tools_info
]
def get_plugin_agent_tools(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
"""
获取插件智能体工具
@@ -891,6 +919,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"tools": [ToolClass1, ToolClass2, ...]
}]
"""
cache_key = pid or "__all__"
with self._plugin_agent_tools_cache_lock:
cached_tools = self._plugin_agent_tools_cache.get(cache_key)
if cached_tools is not None:
return self._copy_plugin_agent_tools(cached_tools)
ret_tools = []
# 创建字典快照避免并发修改
running_plugins_snapshot = dict(self._running_plugins)
@@ -910,6 +944,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
})
except Exception as e:
logger.error(f"获取插件 {plugin_id} 智能体工具出错:{str(e)}")
with self._plugin_agent_tools_cache_lock:
self._plugin_agent_tools_cache[cache_key] = self._copy_plugin_agent_tools(
ret_tools
)
return ret_tools
@staticmethod

View File

@@ -0,0 +1,150 @@
from types import SimpleNamespace
from typing import Iterator, Optional
import pytest
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.factory import MoviePilotToolFactory
from app.core.plugin import PluginManager
from app.utils.singleton import Singleton
class DemoAgentTool(MoviePilotTool):
"""测试用插件工具。"""
name: str = "demo_agent_tool"
description: str = "Demo agent tool for tests."
async def run(self, **kwargs) -> str:
"""返回测试结果。"""
return "ok"
class DemoMessageAgentTool(DemoAgentTool):
"""测试用消息发送插件工具。"""
name: str = "demo_message_agent_tool"
sends_message: bool = True
@pytest.fixture
def plugin_manager() -> Iterator[PluginManager]:
"""构造隔离的插件管理器实例,避免单例缓存污染其它用例。"""
Singleton._instances.pop((PluginManager, (), frozenset()), None)
manager = PluginManager()
yield manager
Singleton._instances.pop((PluginManager, (), frozenset()), None)
def _build_plugin(
tools: list[type[MoviePilotTool]],
state: bool = True,
calls: Optional[list[int]] = None,
) -> SimpleNamespace:
"""构造仅包含 Agent 工具接口的插件实例。"""
def get_agent_tools() -> list[type[MoviePilotTool]]:
"""返回测试预设的工具类列表。"""
if calls is not None:
calls.append(1)
return tools
return SimpleNamespace(
plugin_name="Demo Plugin",
get_state=lambda: state,
get_agent_tools=get_agent_tools,
)
def test_plugin_agent_tools_are_cached(plugin_manager: PluginManager) -> None:
"""插件智能体工具注册表应缓存,避免同一轮启动反复询问插件实例。"""
calls: list[int] = []
plugin_manager.running_plugins["DemoPlugin"] = _build_plugin(
[DemoAgentTool], calls=calls
)
first_result = plugin_manager.get_plugin_agent_tools()
second_result = plugin_manager.get_plugin_agent_tools()
assert len(calls) == 1
assert first_result == second_result
assert first_result[0]["tools"] == [DemoAgentTool]
def test_plugin_agent_tools_cache_returns_copy(plugin_manager: PluginManager) -> None:
"""缓存命中时应返回副本,调用方修改结果不应污染注册表缓存。"""
plugin_manager.running_plugins["DemoPlugin"] = _build_plugin([DemoAgentTool])
first_result = plugin_manager.get_plugin_agent_tools()
first_result[0]["tools"].append(DemoMessageAgentTool)
second_result = plugin_manager.get_plugin_agent_tools()
assert second_result[0]["tools"] == [DemoAgentTool]
def test_plugin_agent_tools_cache_can_be_cleared(
plugin_manager: PluginManager,
) -> None:
"""清理缓存后应重新读取插件当前声明的智能体工具。"""
tools = [DemoAgentTool]
calls: list[int] = []
plugin_manager.running_plugins["DemoPlugin"] = _build_plugin(tools, calls=calls)
assert plugin_manager.get_plugin_agent_tools()[0]["tools"] == [DemoAgentTool]
tools.append(DemoMessageAgentTool)
assert plugin_manager.get_plugin_agent_tools()[0]["tools"] == [DemoAgentTool]
plugin_manager.clear_plugin_agent_tools_cache()
assert plugin_manager.get_plugin_agent_tools()[0]["tools"] == [
DemoAgentTool,
DemoMessageAgentTool,
]
assert len(calls) == 2
def test_factory_reuses_plugin_registry_but_creates_new_tool_instances(
plugin_manager: PluginManager,
) -> None:
"""工具工厂可复用插件注册表缓存,但每次请求仍需创建新的工具实例。"""
calls: list[int] = []
plugin_manager.running_plugins["DemoPlugin"] = _build_plugin(
[DemoAgentTool], calls=calls
)
first_tools = MoviePilotToolFactory.create_tools(
session_id="session-1",
user_id="10001",
)
second_tools = MoviePilotToolFactory.create_tools(
session_id="session-2",
user_id="10002",
)
first_demo_tool = next(tool for tool in first_tools if tool.name == "demo_agent_tool")
second_demo_tool = next(tool for tool in second_tools if tool.name == "demo_agent_tool")
assert len(calls) == 1
assert first_demo_tool is not second_demo_tool
assert first_demo_tool._session_id == "session-1"
assert second_demo_tool._session_id == "session-2"
def test_factory_suppresses_plugin_message_tools_for_subagents(
plugin_manager: PluginManager,
) -> None:
"""子代理静默工具列表不应包含会直接向用户发消息的插件工具。"""
plugin_manager.running_plugins["DemoPlugin"] = _build_plugin(
[DemoAgentTool, DemoMessageAgentTool]
)
tools = MoviePilotToolFactory.create_tools(
session_id="session-1",
user_id="10001",
allow_message_tools=False,
)
tool_names = {tool.name for tool in tools}
assert "demo_agent_tool" in tool_names
assert "demo_message_agent_tool" not in tool_names