diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py index 57205672..7024ab9d 100644 --- a/app/agent/tools/factory.py +++ b/app/agent/tools/factory.py @@ -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 diff --git a/app/core/plugin.py b/app/core/plugin.py index 09c9b362..5eb10787 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -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 diff --git a/tests/test_agent_tool_factory_cache.py b/tests/test_agent_tool_factory_cache.py new file mode 100644 index 00000000..60bfdeac --- /dev/null +++ b/tests/test_agent_tool_factory_cache.py @@ -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