diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py index a98f39ca..86a78ec9 100644 --- a/app/agent/tools/factory.py +++ b/app/agent/tools/factory.py @@ -53,6 +53,10 @@ from app.agent.tools.impl.read_file import ReadFileTool from app.agent.tools.impl.browse_webpage import BrowseWebpageTool from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool from app.agent.tools.impl.query_plugin_capabilities import QueryPluginCapabilitiesTool +from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool +from app.agent.tools.impl.update_plugin_config import UpdatePluginConfigTool +from app.agent.tools.impl.reload_plugin import ReloadPluginTool +from app.agent.tools.impl.query_plugin_data import QueryPluginDataTool from app.agent.tools.impl.run_slash_command import RunSlashCommandTool from app.agent.tools.impl.list_slash_commands import ListSlashCommandsTool from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiersTool @@ -146,6 +150,10 @@ class MoviePilotToolFactory: BrowseWebpageTool, QueryInstalledPluginsTool, QueryPluginCapabilitiesTool, + QueryPluginConfigTool, + UpdatePluginConfigTool, + ReloadPluginTool, + QueryPluginDataTool, RunSlashCommandTool, ListSlashCommandsTool, QueryCustomIdentifiersTool, diff --git a/app/agent/tools/impl/_plugin_tool_utils.py b/app/agent/tools/impl/_plugin_tool_utils.py new file mode 100644 index 00000000..9b1138c3 --- /dev/null +++ b/app/agent/tools/impl/_plugin_tool_utils.py @@ -0,0 +1,73 @@ +"""插件 Agent 工具共享辅助方法""" + +import json +from typing import Any, Optional + +from app.core.plugin import PluginManager + +# 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。 +DEFAULT_PLUGIN_DATA_PREVIEW_CHARS = 12_000 +MAX_PLUGIN_DATA_PREVIEW_CHARS = 50_000 +PLUGIN_DATA_KEY_PREVIEW_LIMIT = 50 +PLUGIN_DATA_TRUNCATION_SUFFIX = "\n...(插件数据内容过长,已截断)" + + +def get_plugin_snapshot(plugin_id: str) -> Optional[dict[str, Any]]: + """ + 获取已安装插件的基础信息快照。 + """ + plugin_manager = PluginManager() + for plugin in plugin_manager.get_local_plugins(): + if plugin.id == plugin_id: + return { + "plugin_id": plugin.id, + "plugin_name": plugin.plugin_name, + "plugin_version": plugin.plugin_version, + "state": plugin.state, + } + return None + + +def clamp_preview_chars(max_chars: Optional[int]) -> int: + """ + 约束插件数据预览长度,避免工具结果无限膨胀。 + """ + if max_chars is None: + return DEFAULT_PLUGIN_DATA_PREVIEW_CHARS + return max(512, min(int(max_chars), MAX_PLUGIN_DATA_PREVIEW_CHARS)) + + +def serialize_for_agent(value: Any) -> str: + """ + 将结果稳定序列化为 JSON 字符串,无法原生序列化的对象退化为字符串。 + """ + return json.dumps(value, ensure_ascii=False, indent=2, default=str) + + +def build_preview_payload(value: Any, max_chars: Optional[int]) -> tuple[bool, int, int, str]: + """ + 为可能很大的插件数据生成预览结果。 + """ + serialized = serialize_for_agent(value) + if len(serialized) <= clamp_preview_chars(max_chars): + return False, len(serialized), len(serialized), serialized + + preview_limit = clamp_preview_chars(max_chars) + preview = serialized[:preview_limit] + PLUGIN_DATA_TRUNCATION_SUFFIX + return True, len(serialized), len(preview), preview + + +def reload_plugin_runtime(plugin_id: str) -> None: + """ + 重载插件并重新注册其命令、定时任务和 API。 + """ + # 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。 + from app.api.endpoints.plugin import register_plugin_api + from app.command import Command + from app.scheduler import Scheduler + + plugin_manager = PluginManager() + plugin_manager.reload_plugin(plugin_id) + Scheduler().update_plugin_job(plugin_id) + Command().init_commands(plugin_id) + register_plugin_api(plugin_id) diff --git a/app/agent/tools/impl/query_plugin_config.py b/app/agent/tools/impl/query_plugin_config.py new file mode 100644 index 00000000..7ed6af76 --- /dev/null +++ b/app/agent/tools/impl/query_plugin_config.py @@ -0,0 +1,88 @@ +"""查询插件配置工具""" + +import json +from typing import Optional, Type + +from pydantic import BaseModel, Field + +from app.agent.tools.base import MoviePilotTool +from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot +from app.core.plugin import PluginManager +from app.log import logger + + +class QueryPluginConfigInput(BaseModel): + """查询插件配置工具的输入参数模型""" + + explanation: str = Field( + ..., + description="Clear explanation of why this tool is being used in the current context", + ) + plugin_id: str = Field( + ..., + description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.", + ) + + +class QueryPluginConfigTool(MoviePilotTool): + name: str = "query_plugin_config" + description: str = ( + "Query the saved configuration of an installed plugin. " + "Returns the current saved config and, when available, the plugin's default config model. " + "Use this before update_plugin_config so you only change the intended keys." + ) + require_admin: bool = True + args_schema: Type[BaseModel] = QueryPluginConfigInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + """生成友好的提示消息""" + plugin_id = kwargs.get("plugin_id", "") + return f"查询插件配置: {plugin_id}" + + @staticmethod + def _query_plugin_config(plugin_id: str) -> str: + """ + 读取插件已保存配置,并尽量补充默认配置模型方便后续精确修改。 + """ + plugin_info = get_plugin_snapshot(plugin_id) + if not plugin_info: + return json.dumps( + { + "success": False, + "message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID", + }, + ensure_ascii=False, + ) + + plugin_manager = PluginManager() + saved_config = plugin_manager.get_plugin_config(plugin_id) or {} + result = { + "success": True, + **plugin_info, + "config": saved_config, + } + + # get_form 的 model 通常就是插件期望的配置结构,适合作为修改前的键参考。 + plugin_instance = plugin_manager.running_plugins.get(plugin_id) + if plugin_instance and hasattr(plugin_instance, "get_form"): + try: + _form_schema, default_model = plugin_instance.get_form() + if default_model is not None: + result["default_model"] = default_model + except Exception as err: + logger.warning(f"读取插件 {plugin_id} 默认配置模型失败: {err}") + + return json.dumps(result, ensure_ascii=False, indent=2, default=str) + + async def run(self, plugin_id: str, **kwargs) -> str: + logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}") + + try: + # 插件配置来自内存配置缓存和运行态插件实例,直接读取即可。 + return self._query_plugin_config(plugin_id) + except Exception as e: + logger.error(f"查询插件配置失败: {e}", exc_info=True) + return json.dumps( + {"success": False, "message": f"查询插件配置时发生错误: {str(e)}"}, + ensure_ascii=False, + ) diff --git a/app/agent/tools/impl/query_plugin_data.py b/app/agent/tools/impl/query_plugin_data.py new file mode 100644 index 00000000..ab3e6e65 --- /dev/null +++ b/app/agent/tools/impl/query_plugin_data.py @@ -0,0 +1,157 @@ +"""查询插件数据工具""" + +import json +from typing import Optional, Type + +from pydantic import BaseModel, Field + +from app.agent.tools.base import MoviePilotTool +from app.agent.tools.impl._plugin_tool_utils import ( + PLUGIN_DATA_KEY_PREVIEW_LIMIT, + build_preview_payload, + get_plugin_snapshot, +) +from app.db.plugindata_oper import PluginDataOper +from app.log import logger + + +class QueryPluginDataInput(BaseModel): + """查询插件数据工具的输入参数模型""" + + explanation: str = Field( + ..., + description="Clear explanation of why this tool is being used in the current context", + ) + plugin_id: str = Field( + ..., + description="The plugin ID to query. Use query_installed_plugins first to discover valid plugin IDs.", + ) + key: Optional[str] = Field( + None, + description="Optional plugin data key. If omitted, returns all plugin data entries for the plugin.", + ) + max_chars: Optional[int] = Field( + None, + description="Maximum number of preview characters to return when plugin data is too large. Default 12000, capped at 50000.", + ) + + +class QueryPluginDataTool(MoviePilotTool): + name: str = "query_plugin_data" + description: str = ( + "Query persisted data of an installed plugin. " + "Optionally specify a key to read a single data item; otherwise all plugin data entries are returned. " + "When the result is too large, the tool automatically truncates it and returns a preview instead." + ) + require_admin: bool = True + args_schema: Type[BaseModel] = QueryPluginDataInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + """生成友好的提示消息""" + plugin_id = kwargs.get("plugin_id", "") + key = kwargs.get("key") + if key: + return f"查询插件数据: {plugin_id}.{key}" + return f"查询插件全部数据: {plugin_id}" + + async def _query_plugin_data( + self, plugin_id: str, key: Optional[str] = None, max_chars: Optional[int] = None + ) -> str: + """ + 插件数据改走异步 ORM 查询,避免再套一层线程池。 + """ + plugin_info = get_plugin_snapshot(plugin_id) + if not plugin_info: + return json.dumps( + { + "success": False, + "message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID", + }, + ensure_ascii=False, + ) + + plugin_data_oper = PluginDataOper() + if key: + value = await plugin_data_oper.async_get_data(plugin_id, key) + if value is None: + return json.dumps( + { + "success": True, + **plugin_info, + "key": key, + "found": False, + "message": f"插件 {plugin_id} 没有数据项 {key}", + }, + ensure_ascii=False, + indent=2, + ) + + truncated, total_chars, returned_chars, preview = build_preview_payload( + value, max_chars + ) + result = { + "success": True, + **plugin_info, + "key": key, + "found": True, + "truncated": truncated, + "total_chars": total_chars, + "returned_chars": returned_chars, + } + if truncated: + result["value_preview"] = preview + result["message"] = "插件数据内容过大,已截断预览" + else: + result["value"] = value + return json.dumps(result, ensure_ascii=False, indent=2, default=str) + + rows = await plugin_data_oper.async_get_data_all(plugin_id) or [] + data_map = {row.key: row.value for row in rows} + keys = list(data_map.keys()) + key_preview = keys[:PLUGIN_DATA_KEY_PREVIEW_LIMIT] + + result = { + "success": True, + **plugin_info, + "count": len(data_map), + "keys": key_preview, + "keys_truncated": len(keys) > PLUGIN_DATA_KEY_PREVIEW_LIMIT, + } + + if not data_map: + result["data"] = {} + result["truncated"] = False + return json.dumps(result, ensure_ascii=False, indent=2, default=str) + + truncated, total_chars, returned_chars, preview = build_preview_payload( + data_map, max_chars + ) + result["truncated"] = truncated + result["total_chars"] = total_chars + result["returned_chars"] = returned_chars + if truncated: + result["data_preview"] = preview + result["message"] = "插件数据内容过大,已截断。请传入 key 精确查询单个数据项。" + else: + result["data"] = data_map + return json.dumps(result, ensure_ascii=False, indent=2, default=str) + + async def run( + self, + plugin_id: str, + key: Optional[str] = None, + max_chars: Optional[int] = None, + **kwargs, + ) -> str: + logger.info( + f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, key={key}" + ) + + try: + return await self._query_plugin_data(plugin_id, key, max_chars) + except Exception as e: + logger.error(f"查询插件数据失败: {e}", exc_info=True) + return json.dumps( + {"success": False, "message": f"查询插件数据时发生错误: {str(e)}"}, + ensure_ascii=False, + ) diff --git a/app/agent/tools/impl/reload_plugin.py b/app/agent/tools/impl/reload_plugin.py new file mode 100644 index 00000000..a13d20b6 --- /dev/null +++ b/app/agent/tools/impl/reload_plugin.py @@ -0,0 +1,84 @@ +"""重载插件工具""" + +import json +from typing import Optional, Type + +from pydantic import BaseModel, Field + +from app.agent.tools.base import MoviePilotTool +from app.agent.tools.impl._plugin_tool_utils import ( + get_plugin_snapshot, + reload_plugin_runtime, +) +from app.log import logger + + +class ReloadPluginInput(BaseModel): + """重载插件工具的输入参数模型""" + + explanation: str = Field( + ..., + description="Clear explanation of why this tool is being used in the current context", + ) + plugin_id: str = Field( + ..., + description="The plugin ID to reload so the latest saved config takes effect.", + ) + + +class ReloadPluginTool(MoviePilotTool): + name: str = "reload_plugin" + description: str = ( + "Reload an installed plugin so its latest saved configuration takes effect. " + "This also refreshes the plugin's registered commands, scheduled services, and API routes." + ) + require_admin: bool = True + args_schema: Type[BaseModel] = ReloadPluginInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + """生成友好的提示消息""" + plugin_id = kwargs.get("plugin_id", "") + return f"重载插件: {plugin_id}" + + @staticmethod + def _reload_plugin_sync(plugin_id: str) -> str: + """ + 按后台接口同样的流程重载插件,确保最新配置和注册信息一起刷新。 + """ + plugin_info = get_plugin_snapshot(plugin_id) + if not plugin_info: + return json.dumps( + { + "success": False, + "message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID", + }, + ensure_ascii=False, + ) + + reload_plugin_runtime(plugin_id) + refreshed_plugin = get_plugin_snapshot(plugin_id) or plugin_info + + return json.dumps( + { + "success": True, + **refreshed_plugin, + "message": "插件已重载,最新配置已生效", + }, + ensure_ascii=False, + indent=2, + default=str, + ) + + async def run(self, plugin_id: str, **kwargs) -> str: + logger.info(f"执行工具: {self.name}, 参数: plugin_id={plugin_id}") + + try: + return await self.run_blocking( + "plugin", self._reload_plugin_sync, plugin_id + ) + except Exception as e: + logger.error(f"重载插件失败: {e}", exc_info=True) + return json.dumps( + {"success": False, "message": f"重载插件时发生错误: {str(e)}"}, + ensure_ascii=False, + ) diff --git a/app/agent/tools/impl/update_plugin_config.py b/app/agent/tools/impl/update_plugin_config.py new file mode 100644 index 00000000..fd0185d9 --- /dev/null +++ b/app/agent/tools/impl/update_plugin_config.py @@ -0,0 +1,153 @@ +"""修改插件配置工具""" + +import json +from typing import Any, Dict, List, Optional, Type + +from pydantic import BaseModel, Field + +from app.agent.tools.base import MoviePilotTool +from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot +from app.core.plugin import PluginManager +from app.log import logger + + +class UpdatePluginConfigInput(BaseModel): + """修改插件配置工具的输入参数模型""" + + explanation: str = Field( + ..., + description="Clear explanation of why this tool is being used in the current context", + ) + plugin_id: str = Field( + ..., + description="The plugin ID to update. Use query_plugin_config first to inspect the current config.", + ) + updates: Optional[Dict[str, Any]] = Field( + None, + description=( + "Config items to save. By default this tool merges these keys into the existing config " + "instead of replacing the whole config." + ), + ) + remove_keys: Optional[List[str]] = Field( + None, + description="Optional config keys to remove from the saved plugin config.", + ) + replace: Optional[bool] = Field( + False, + description=( + "Whether to replace the entire saved config with 'updates'. " + "Default false, which performs a partial merge update." + ), + ) + + +class UpdatePluginConfigTool(MoviePilotTool): + name: str = "update_plugin_config" + description: str = ( + "Update the saved configuration of an installed plugin. " + "By default this performs a partial merge update and does NOT reload the plugin automatically. " + "Call reload_plugin afterwards to apply the latest saved config to the running plugin." + ) + require_admin: bool = True + args_schema: Type[BaseModel] = UpdatePluginConfigInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + """生成友好的提示消息""" + plugin_id = kwargs.get("plugin_id", "") + replace = kwargs.get("replace", False) + action = "覆盖插件配置" if replace else "修改插件配置" + return f"{action}: {plugin_id}" + + @staticmethod + async def _update_plugin_config( + plugin_id: str, + updates: Optional[Dict[str, Any]] = None, + remove_keys: Optional[List[str]] = None, + replace: bool = False, + ) -> str: + """ + 仅异步保存插件配置,不主动生效,让 Agent 可以先批量改完再显式重载插件。 + """ + plugin_info = get_plugin_snapshot(plugin_id) + if not plugin_info: + return json.dumps( + { + "success": False, + "message": f"插件 {plugin_id} 不存在,请先使用 query_installed_plugins 查询有效插件 ID", + }, + ensure_ascii=False, + ) + + remove_keys = remove_keys or [] + if not replace and not updates and not remove_keys: + return json.dumps( + {"success": False, "message": "没有提供任何需要修改的配置项"}, + ensure_ascii=False, + ) + + plugin_manager = PluginManager() + current_config = dict(plugin_manager.get_plugin_config(plugin_id) or {}) + + # merge 模式以当前保存值为基准,replace 模式则从空配置开始重建。 + next_config = {} if replace else dict(current_config) + if updates: + next_config.update(updates) + for key in remove_keys: + next_config.pop(key, None) + + changed_keys = sorted( + key + for key in set(current_config.keys()) | set(next_config.keys()) + if current_config.get(key) != next_config.get(key) + or (key in current_config) != (key in next_config) + ) + + if not await plugin_manager.async_save_plugin_config(plugin_id, next_config): + return json.dumps( + { + "success": False, + "message": f"保存插件 {plugin_id} 配置失败", + }, + ensure_ascii=False, + ) + + return json.dumps( + { + "success": True, + **plugin_info, + "message": "插件配置已保存,请调用 reload_plugin 使最新配置生效", + "replace": replace, + "changed_keys": changed_keys, + "removed_keys": remove_keys, + "config_requires_reload": True, + "previous_config": current_config, + "saved_config": next_config, + }, + ensure_ascii=False, + indent=2, + default=str, + ) + + async def run( + self, + plugin_id: str, + updates: Optional[Dict[str, Any]] = None, + remove_keys: Optional[List[str]] = None, + replace: bool = False, + **kwargs, + ) -> str: + logger.info( + f"执行工具: {self.name}, 参数: plugin_id={plugin_id}, replace={replace}" + ) + + try: + return await self._update_plugin_config( + plugin_id, updates, remove_keys, replace + ) + except Exception as e: + logger.error(f"修改插件配置失败: {e}", exc_info=True) + return json.dumps( + {"success": False, "message": f"修改插件配置时发生错误: {str(e)}"}, + ensure_ascii=False, + ) diff --git a/app/core/plugin.py b/app/core/plugin.py index 40304428..72e16ee4 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -686,6 +686,20 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): SystemConfigOper().set(self._config_key % pid, conf) return True + async def async_save_plugin_config( + self, pid: str, conf: dict, force: bool = False + ) -> bool: + """ + 异步保存插件配置。 + :param pid: 插件ID + :param conf: 配置 + :param force: 强制保存 + """ + if not force and not self._plugins.get(pid): + return False + await SystemConfigOper().async_set(self._config_key % pid, conf) + return True + def delete_plugin_config(self, pid: str) -> bool: """ 删除插件配置 diff --git a/app/db/models/plugindata.py b/app/db/models/plugindata.py index 3e8e46dd..dfeb94a2 100644 --- a/app/db/models/plugindata.py +++ b/app/db/models/plugindata.py @@ -1,7 +1,14 @@ -from sqlalchemy import Column, String, JSON +from sqlalchemy import Column, String, JSON, select +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session -from app.db import db_query, db_update, get_id_column, Base +from app.db import ( + db_query, + db_update, + async_db_query, + get_id_column, + Base, +) class PluginData(Base): @@ -18,11 +25,27 @@ class PluginData(Base): def get_plugin_data(cls, db: Session, plugin_id: str): return db.query(cls).filter(cls.plugin_id == plugin_id).all() + @classmethod + @async_db_query + async def async_get_plugin_data(cls, db: AsyncSession, plugin_id: str): + result = await db.execute(select(cls).where(cls.plugin_id == plugin_id)) + return result.scalars().all() + @classmethod @db_query def get_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str): return db.query(cls).filter(cls.plugin_id == plugin_id, cls.key == key).first() + @classmethod + @async_db_query + async def async_get_plugin_data_by_key( + cls, db: AsyncSession, plugin_id: str, key: str + ): + result = await db.execute( + select(cls).where(cls.plugin_id == plugin_id, cls.key == key) + ) + return result.scalar_one_or_none() + @classmethod @db_update def del_plugin_data_by_key(cls, db: Session, plugin_id: str, key: str): @@ -37,3 +60,11 @@ class PluginData(Base): @db_query def get_plugin_data_by_plugin_id(cls, db: Session, plugin_id: str): return db.query(cls).filter(cls.plugin_id == plugin_id).all() + + @classmethod + @async_db_query + async def async_get_plugin_data_by_plugin_id( + cls, db: AsyncSession, plugin_id: str + ): + result = await db.execute(select(cls).where(cls.plugin_id == plugin_id)) + return result.scalars().all() diff --git a/app/db/plugindata_oper.py b/app/db/plugindata_oper.py index 09b62876..317724bc 100644 --- a/app/db/plugindata_oper.py +++ b/app/db/plugindata_oper.py @@ -38,6 +38,21 @@ class PluginDataOper(DbOper): else: return PluginData.get_plugin_data(self._db, plugin_id) + async def async_get_data(self, plugin_id: str, key: Optional[str] = None) -> Any: + """ + 异步获取插件数据。 + :param plugin_id: 插件id + :param key: 数据key + """ + if key: + data = await PluginData.async_get_plugin_data_by_key( + self._db, plugin_id, key + ) + if not data: + return None + return data.value + return await PluginData.async_get_plugin_data(self._db, plugin_id) + def del_data(self, plugin_id: str, key: Optional[str] = None) -> Any: """ 删除插件数据 @@ -61,3 +76,10 @@ class PluginDataOper(DbOper): :param plugin_id: 插件id """ return PluginData.get_plugin_data_by_plugin_id(self._db, plugin_id) + + async def async_get_data_all(self, plugin_id: str) -> Any: + """ + 异步获取插件所有数据。 + :param plugin_id: 插件id + """ + return await PluginData.async_get_plugin_data_by_plugin_id(self._db, plugin_id) diff --git a/tests/test_agent_plugin_tools.py b/tests/test_agent_plugin_tools.py new file mode 100644 index 00000000..40a70d6d --- /dev/null +++ b/tests/test_agent_plugin_tools.py @@ -0,0 +1,120 @@ +import asyncio +import json +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool +from app.agent.tools.impl.query_plugin_data import QueryPluginDataTool +from app.agent.tools.impl.reload_plugin import ReloadPluginTool +from app.agent.tools.impl.update_plugin_config import UpdatePluginConfigTool + + +class TestAgentPluginTools(unittest.TestCase): + @staticmethod + def _plugin_snapshot(state: bool = True) -> dict: + return { + "plugin_id": "DemoPlugin", + "plugin_name": "Demo Plugin", + "plugin_version": "1.0.0", + "state": state, + } + + def test_query_plugin_config_returns_saved_config_and_default_model(self): + tool = QueryPluginConfigTool(session_id="session-1", user_id="10001") + plugin_manager = MagicMock() + plugin_manager.get_plugin_config.return_value = {"enabled": True} + plugin_instance = MagicMock() + plugin_instance.get_form.return_value = (None, {"enabled": False, "interval": 10}) + plugin_manager.running_plugins = {"DemoPlugin": plugin_instance} + + with patch( + "app.agent.tools.impl.query_plugin_config.get_plugin_snapshot", + return_value=self._plugin_snapshot(), + ), patch( + "app.agent.tools.impl.query_plugin_config.PluginManager", + return_value=plugin_manager, + ): + result = asyncio.run(tool.run(plugin_id="DemoPlugin")) + + payload = json.loads(result) + self.assertTrue(payload["success"]) + self.assertEqual(payload["config"], {"enabled": True}) + self.assertEqual(payload["default_model"], {"enabled": False, "interval": 10}) + + def test_update_plugin_config_merges_and_removes_keys_without_reloading(self): + tool = UpdatePluginConfigTool(session_id="session-1", user_id="10001") + plugin_manager = MagicMock() + plugin_manager.get_plugin_config.return_value = { + "enabled": False, + "interval": 30, + "token": "legacy-token", + } + plugin_manager.async_save_plugin_config = AsyncMock(return_value=True) + + with patch( + "app.agent.tools.impl.update_plugin_config.get_plugin_snapshot", + return_value=self._plugin_snapshot(), + ), patch( + "app.agent.tools.impl.update_plugin_config.PluginManager", + return_value=plugin_manager, + ): + result = asyncio.run( + tool.run( + plugin_id="DemoPlugin", + updates={"enabled": True}, + remove_keys=["token"], + ) + ) + + payload = json.loads(result) + self.assertTrue(payload["success"]) + self.assertTrue(payload["config_requires_reload"]) + self.assertEqual(payload["saved_config"], {"enabled": True, "interval": 30}) + plugin_manager.async_save_plugin_config.assert_awaited_once_with( + "DemoPlugin", + {"enabled": True, "interval": 30}, + ) + + def test_reload_plugin_triggers_runtime_refresh(self): + tool = ReloadPluginTool(session_id="session-1", user_id="10001") + + with patch( + "app.agent.tools.impl.reload_plugin.get_plugin_snapshot", + side_effect=[self._plugin_snapshot(), self._plugin_snapshot(state=False)], + ), patch( + "app.agent.tools.impl.reload_plugin.reload_plugin_runtime" + ) as reload_plugin_runtime: + result = asyncio.run(tool.run(plugin_id="DemoPlugin")) + + payload = json.loads(result) + self.assertTrue(payload["success"]) + self.assertFalse(payload["state"]) + reload_plugin_runtime.assert_called_once_with("DemoPlugin") + + def test_query_plugin_data_truncates_large_payload(self): + tool = QueryPluginDataTool(session_id="session-1", user_id="10001") + plugin_data_oper = MagicMock() + plugin_data_oper.async_get_data_all = AsyncMock(return_value=[ + SimpleNamespace(key="payload", value={"text": "x" * 5000}) + ]) + + with patch( + "app.agent.tools.impl.query_plugin_data.get_plugin_snapshot", + return_value=self._plugin_snapshot(), + ), patch( + "app.agent.tools.impl.query_plugin_data.PluginDataOper", + return_value=plugin_data_oper, + ): + result = asyncio.run(tool.run(plugin_id="DemoPlugin", max_chars=200)) + + payload = json.loads(result) + self.assertTrue(payload["success"]) + self.assertTrue(payload["truncated"]) + self.assertIn("data_preview", payload) + self.assertNotIn("data", payload) + self.assertIn("已截断", payload["data_preview"]) + + +if __name__ == "__main__": + unittest.main()