mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
feat(plugin): implement caching for plugin agent tools registry
This commit is contained in:
+77
-64
@@ -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_download_tasks import AddDownloadTasksTool
|
||||||
from app.agent.tools.impl.add_subscribe import AddSubscribeTool
|
from app.agent.tools.impl.add_subscribe import AddSubscribeTool
|
||||||
@@ -93,66 +93,7 @@ class MoviePilotToolFactory:
|
|||||||
MoviePilot工具工厂
|
MoviePilot工具工厂
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
|
BUILTIN_TOOL_CLASSES: tuple[Type[MoviePilotTool], ...] = (
|
||||||
# 文件系统、命令执行、历史检索或交互确认能力。AskUserChoiceTool 仅在支持按钮
|
|
||||||
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
|
|
||||||
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
|
|
||||||
"list_directory",
|
|
||||||
"write_file",
|
|
||||||
"read_file",
|
|
||||||
"edit_file",
|
|
||||||
"execute_command",
|
|
||||||
"query_activity_log",
|
|
||||||
"ask_user_choice",
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _should_enable_choice_tool(channel: str = None) -> bool:
|
|
||||||
if not channel:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
message_channel = MessageChannel(channel)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return ChannelCapabilityManager.supports_buttons(
|
|
||||||
message_channel
|
|
||||||
) and ChannelCapabilityManager.supports_callbacks(message_channel)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_tool_selector_always_include_names(
|
|
||||||
cls, tools: List[MoviePilotTool]
|
|
||||||
) -> List[str]:
|
|
||||||
"""
|
|
||||||
返回当前实际已加载且需要绕过工具筛选的工具名。
|
|
||||||
|
|
||||||
`LLMToolSelectorMiddleware` 会校验 `always_include` 中的工具名是否
|
|
||||||
存在于当前请求里,因此这里必须根据运行时工具列表做交集过滤。
|
|
||||||
"""
|
|
||||||
available_tool_names = {
|
|
||||||
tool.name for tool in tools if getattr(tool, "name", None)
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
tool_name
|
|
||||||
for tool_name in cls.TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES
|
|
||||||
if tool_name in available_tool_names
|
|
||||||
]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_tools(
|
|
||||||
session_id: str,
|
|
||||||
user_id: str,
|
|
||||||
channel: str = None,
|
|
||||||
source: str = None,
|
|
||||||
username: str = None,
|
|
||||||
stream_handler: Callable = None,
|
|
||||||
agent_context: dict = None,
|
|
||||||
allow_message_tools: bool = True,
|
|
||||||
) -> List[MoviePilotTool]:
|
|
||||||
"""
|
|
||||||
创建MoviePilot工具列表
|
|
||||||
"""
|
|
||||||
tools = []
|
|
||||||
tool_definitions = [
|
|
||||||
SearchMediaTool,
|
SearchMediaTool,
|
||||||
SearchPersonTool,
|
SearchPersonTool,
|
||||||
SearchPersonCreditsTool,
|
SearchPersonCreditsTool,
|
||||||
@@ -230,12 +171,84 @@ class MoviePilotToolFactory:
|
|||||||
UpdateCustomIdentifiersTool,
|
UpdateCustomIdentifiersTool,
|
||||||
QuerySystemSettingsTool,
|
QuerySystemSettingsTool,
|
||||||
UpdateSystemSettingsTool,
|
UpdateSystemSettingsTool,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 这些通用工具需要始终保留,避免大工具集裁剪后让 Agent 丢失基础的
|
||||||
|
# 文件系统、命令执行、历史检索或交互确认能力。AskUserChoiceTool 仅在支持按钮
|
||||||
|
# 的渠道中才会实际注入,因此后续会再按已加载工具做一次求交集。
|
||||||
|
TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES = (
|
||||||
|
"list_directory",
|
||||||
|
"write_file",
|
||||||
|
"read_file",
|
||||||
|
"edit_file",
|
||||||
|
"execute_command",
|
||||||
|
"query_activity_log",
|
||||||
|
"ask_user_choice",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_enable_choice_tool(channel: Optional[str] = None) -> bool:
|
||||||
|
if not channel:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
message_channel = MessageChannel(channel)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return ChannelCapabilityManager.supports_buttons(
|
||||||
|
message_channel
|
||||||
|
) and ChannelCapabilityManager.supports_callbacks(message_channel)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_tool_selector_always_include_names(
|
||||||
|
cls, tools: List[MoviePilotTool]
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
返回当前实际已加载且需要绕过工具筛选的工具名。
|
||||||
|
|
||||||
|
`LLMToolSelectorMiddleware` 会校验 `always_include` 中的工具名是否
|
||||||
|
存在于当前请求里,因此这里必须根据运行时工具列表做交集过滤。
|
||||||
|
"""
|
||||||
|
available_tool_names = {
|
||||||
|
tool.name for tool in tools if getattr(tool, "name", None)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
tool_name
|
||||||
|
for tool_name in cls.TOOL_SELECTOR_ALWAYS_INCLUDE_NAMES
|
||||||
|
if tool_name in available_tool_names
|
||||||
]
|
]
|
||||||
if MoviePilotToolFactory._should_enable_choice_tool(channel):
|
|
||||||
|
@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(AskUserChoiceTool)
|
||||||
tool_definitions.append(SendLocalFileTool)
|
tool_definitions.append(SendLocalFileTool)
|
||||||
if AgentCapabilityManager.supports_audio_output():
|
if AgentCapabilityManager.supports_audio_output():
|
||||||
tool_definitions.append(SendVoiceMessageTool)
|
tool_definitions.append(SendVoiceMessageTool)
|
||||||
|
return tool_definitions
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_tools(
|
||||||
|
cls,
|
||||||
|
session_id: str,
|
||||||
|
user_id: str,
|
||||||
|
channel: str = None,
|
||||||
|
source: str = None,
|
||||||
|
username: str = None,
|
||||||
|
stream_handler: Callable = None,
|
||||||
|
agent_context: dict = None,
|
||||||
|
allow_message_tools: bool = True,
|
||||||
|
) -> List[MoviePilotTool]:
|
||||||
|
"""
|
||||||
|
创建MoviePilot工具列表
|
||||||
|
"""
|
||||||
|
tools = []
|
||||||
|
tool_definitions = cls._get_builtin_tool_classes(channel)
|
||||||
# 创建内置工具
|
# 创建内置工具
|
||||||
for ToolClass in tool_definitions:
|
for ToolClass in tool_definitions:
|
||||||
tool = ToolClass(session_id=session_id, user_id=user_id)
|
tool = ToolClass(session_id=session_id, user_id=user_id)
|
||||||
@@ -282,9 +295,9 @@ class MoviePilotToolFactory:
|
|||||||
|
|
||||||
builtin_tools_count = len(tool_definitions)
|
builtin_tools_count = len(tool_definitions)
|
||||||
if plugin_tools_count > 0:
|
if plugin_tools_count > 0:
|
||||||
logger.info(
|
logger.debug(
|
||||||
f"成功创建 {len(tools)} 个MoviePilot工具(内置工具: {builtin_tools_count} 个,插件工具: {plugin_tools_count} 个)"
|
f"成功创建 {len(tools)} 个MoviePilot工具(内置工具: {builtin_tools_count} 个,插件工具: {plugin_tools_count} 个)"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"成功创建 {len(tools)} 个MoviePilot工具")
|
logger.debug(f"成功创建 {len(tools)} 个MoviePilot工具")
|
||||||
return tools
|
return tools
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
self._stop_monitor_event = threading.Event()
|
self._stop_monitor_event = threading.Event()
|
||||||
# 本地插件同步写入运行目录后的短时忽略窗口
|
# 本地插件同步写入运行目录后的短时忽略窗口
|
||||||
self._recent_local_sync: Dict[str, float] = {}
|
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:
|
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
|
||||||
self.__start_monitor()
|
self.__start_monitor()
|
||||||
@@ -112,6 +115,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
eventmanager.disable_event_handler(plugin)
|
eventmanager.disable_event_handler(plugin)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
|
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):
|
def init_plugin(self, plugin_id: str, conf: dict):
|
||||||
"""
|
"""
|
||||||
@@ -131,6 +135,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
# 禁用插件类的事件处理器
|
# 禁用插件类的事件处理器
|
||||||
eventmanager.disable_event_handler(type(plugin))
|
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):
|
def stop(self, pid: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
@@ -167,6 +179,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
self._running_plugins = {}
|
self._running_plugins = {}
|
||||||
# 清除所有插件模块缓存
|
# 清除所有插件模块缓存
|
||||||
self._clear_plugin_modules()
|
self._clear_plugin_modules()
|
||||||
|
self.clear_plugin_agent_tools_cache()
|
||||||
logger.info("插件停止完成")
|
logger.info("插件停止完成")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -882,6 +895,21 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
logger.error(f"获取插件 {plugin_id} 动作出错:{str(e)}")
|
logger.error(f"获取插件 {plugin_id} 动作出错:{str(e)}")
|
||||||
return ret_actions
|
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]]:
|
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, ...]
|
"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 = []
|
ret_tools = []
|
||||||
# 创建字典快照避免并发修改
|
# 创建字典快照避免并发修改
|
||||||
running_plugins_snapshot = dict(self._running_plugins)
|
running_plugins_snapshot = dict(self._running_plugins)
|
||||||
@@ -910,6 +944,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取插件 {plugin_id} 智能体工具出错:{str(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
|
return ret_tools
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user