From dfabd695a8e9866ebd30c098cb1117ffda0355a2 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 12 Jun 2026 16:26:00 +0800 Subject: [PATCH] add: query_doctor_report agent tool --- app/agent/prompt/System Core Prompt.txt | 1 + app/agent/tools/factory.py | 3 + app/agent/tools/impl/query_doctor_report.py | 126 ++++++++++++++++++++ skills/feedback-issue/SKILL.md | 5 +- tests/test_agent_doctor_tool.py | 109 +++++++++++++++++ 5 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 app/agent/tools/impl/query_doctor_report.py create mode 100644 tests/test_agent_doctor_tool.py diff --git a/app/agent/prompt/System Core Prompt.txt b/app/agent/prompt/System Core Prompt.txt index 69c99a62..874bc960 100644 --- a/app/agent/prompt/System Core Prompt.txt +++ b/app/agent/prompt/System Core Prompt.txt @@ -58,6 +58,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel - Use parallel tool calls by default for independent read-only or diagnostic work. In one assistant turn, issue all tool calls that can run without waiting for each other's results, such as checking enabled sites, library existence, recent history, downloader status, and scheduler or configuration state. - Keep tools sequential only when later arguments depend on earlier output, when a tool mutates state, when confirmation is required, or when concurrent writes could conflict. - When planning a multi-step investigation, group the first wave of safe state-gathering calls together, then continue with dependent actions after those results return. +- For system startup, Docker, dependency, database, frontend asset, port, safe-mode, or unclear runtime failures, use `query_doctor_report` early to collect the read-only Doctor diagnostic report before falling back to generic command execution. - Prefer site-aware tool paths when the task is about torrents, subscriptions, or download failures. `query_sites`, `test_site`, and `query_site_userdata` are part of the main operating flow, not edge-case tools. - If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding. - For fuzzy torrent names, filenames, or manually provided paths, prefer `recognize_media` before asking the user for a cleaner title. diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py index 2f53ce69..5e35ea8f 100644 --- a/app/agent/tools/factory.py +++ b/app/agent/tools/factory.py @@ -74,6 +74,7 @@ from app.agent.tools.impl.uninstall_plugin import UninstallPluginTool 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 +from app.agent.tools.impl.query_doctor_report import QueryDoctorReportTool from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifiersTool from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool @@ -99,6 +100,7 @@ class MoviePilotToolFactory: "read_file", "edit_file", "execute_command", + "query_doctor_report", "send_message", "ask_user_choice", ) @@ -220,6 +222,7 @@ class MoviePilotToolFactory: UninstallPluginTool, RunSlashCommandTool, ListSlashCommandsTool, + QueryDoctorReportTool, QueryCustomIdentifiersTool, UpdateCustomIdentifiersTool, QuerySystemSettingsTool, diff --git a/app/agent/tools/impl/query_doctor_report.py b/app/agent/tools/impl/query_doctor_report.py new file mode 100644 index 00000000..46fe0fe4 --- /dev/null +++ b/app/agent/tools/impl/query_doctor_report.py @@ -0,0 +1,126 @@ +"""查询 MoviePilot Doctor 诊断报告工具。""" + +import json +from typing import Any, Optional, Type + +from pydantic import BaseModel, Field + +from app.agent.tools.base import MoviePilotTool +from app.agent.tools.tags import ToolTag +from app.doctor import run_doctor +from app.log import logger + + +class QueryDoctorReportInput(BaseModel): + """查询 Doctor 诊断报告工具的输入参数模型。""" + + explanation: Optional[str] = Field( + None, + description="Clear explanation of why this tool is being used in the current context", + ) + deep: Optional[bool] = Field( + False, + description=( + "Whether to run deeper checks. When true, doctor may perform slower environment probes " + "such as PostgreSQL TCP connectivity checks." + ), + ) + include_details: Optional[bool] = Field( + True, + description=( + "Whether to include full doctor findings with details and context. Set false for a compact " + "summary when only overall status and finding titles are needed." + ), + ) + + +class QueryDoctorReportTool(MoviePilotTool): + """ + Doctor 离线诊断报告查询工具。 + """ + + name: str = "query_doctor_report" + tags: list[str] = [ + ToolTag.Read, + ToolTag.System, + ToolTag.Admin, + ] + description: str = ( + "Run MoviePilot Doctor in read-only mode and return a structured diagnostic report for troubleshooting. " + "Use this tool when analyzing startup failures, Docker/runtime issues, port conflicts, dependency problems, " + "database health, frontend assets, safe mode, or recent log error clues. This tool never applies fixes." + ) + require_admin: bool = True + args_schema: Type[BaseModel] = QueryDoctorReportInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + """根据查询参数生成友好的提示消息。""" + if kwargs.get("deep"): + return "运行 Doctor 深度诊断" + return "运行 Doctor 诊断" + + @staticmethod + def _compact_report(report: dict[str, Any]) -> dict[str, Any]: + """压缩诊断报告,保留 Agent 判断问题所需的核心字段。""" + return { + "schema_version": report.get("schema_version"), + "status": report.get("status"), + "generated_at": report.get("generated_at"), + "version": report.get("version"), + "environment": report.get("environment"), + "summary": report.get("summary"), + "findings": [ + { + "id": item.get("id"), + "severity": item.get("severity"), + "status": item.get("status"), + "title": item.get("title"), + "fixable": item.get("fixable"), + "fixed": item.get("fixed"), + } + for item in report.get("findings") or [] + if isinstance(item, dict) + ], + } + + @staticmethod + def _run_doctor_report(deep: bool = False) -> dict[str, Any]: + """在线程池中运行只读 Doctor 诊断。""" + return run_doctor(deep=bool(deep)).to_dict() + + async def run( + self, + deep: Optional[bool] = False, + include_details: Optional[bool] = True, + **kwargs, + ) -> str: + """ + 运行只读 Doctor 诊断并返回 JSON 字符串。 + """ + logger.info( + f"执行工具: {self.name}, deep={bool(deep)}, include_details={bool(include_details)}" + ) + try: + report = await self.run_blocking("default", self._run_doctor_report, bool(deep)) + if not include_details: + report = self._compact_report(report) + return json.dumps( + { + "success": True, + "deep": bool(deep), + "include_details": bool(include_details), + "report": report, + }, + ensure_ascii=False, + indent=2, + default=str, + ) + except Exception as err: + logger.error(f"查询 Doctor 诊断报告失败: {err}", exc_info=True) + return json.dumps( + { + "success": False, + "message": f"查询 Doctor 诊断报告时发生错误: {str(err)}", + }, + ensure_ascii=False, + ) diff --git a/skills/feedback-issue/SKILL.md b/skills/feedback-issue/SKILL.md index c99b6d5f..dd0922d2 100644 --- a/skills/feedback-issue/SKILL.md +++ b/skills/feedback-issue/SKILL.md @@ -69,8 +69,9 @@ Only enter this skill when both conditions are true: bug, or the user explicitly asks to escalate after troubleshooting. For ordinary symptoms, first use normal Agent diagnostic tools such as -subscription, download, site, plugin, scheduler, and log queries. If the -cause is local configuration or environment, do not file an issue. +`query_doctor_report`, subscription, download, site, plugin, scheduler, +and log queries. If the cause is local configuration or environment, do +not file an issue. ### 2. Collect Diagnostics diff --git a/tests/test_agent_doctor_tool.py b/tests/test_agent_doctor_tool.py new file mode 100644 index 00000000..c54ae187 --- /dev/null +++ b/tests/test_agent_doctor_tool.py @@ -0,0 +1,109 @@ +import asyncio +import json +from datetime import datetime +from unittest.mock import patch + +from app.agent.tools.factory import MoviePilotToolFactory +from app.agent.tools.impl.query_doctor_report import QueryDoctorReportTool +from app.agent.tools.manager import MoviePilotToolsManager +from app.doctor.models import ( + DoctorFinding, + DoctorFindingStatus, + DoctorReport, + DoctorSeverity, +) + + +def _doctor_report() -> DoctorReport: + """构造一份稳定的 doctor 测试报告。""" + report = DoctorReport( + generated_at=datetime(2026, 6, 12, 12, 0, 0), + version="v2.test", + environment={ + "runtime": "Docker", + "config_path": "/config", + "is_docker": True, + }, + ) + report.add_finding( + DoctorFinding( + id="logs.moviepilot.recent_errors", + severity=DoctorSeverity.Warn, + status=DoctorFindingStatus.Degraded, + title="最近日志存在错误线索", + detail="ERROR demo Cookie: ", + recommendation="结合前后的启动日志定位异常。", + context={"log_file": "/config/logs/moviepilot.log", "matches": 1}, + ) + ) + return report + + +def test_factory_registers_doctor_report_tool(): + """工具工厂应注册 doctor 诊断报告工具。""" + with patch( + "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", + return_value=[], + ): + tools = MoviePilotToolFactory.create_tools( + session_id="doctor-session", + user_id="10001", + ) + + tool_names = {tool.name for tool in tools} + assert "query_doctor_report" in tool_names + + +def test_query_doctor_report_returns_readonly_report(): + """doctor 工具应以只读方式返回结构化诊断报告。""" + tool = QueryDoctorReportTool(session_id="doctor-session", user_id="10001") + + with patch( + "app.agent.tools.impl.query_doctor_report.run_doctor", + return_value=_doctor_report(), + ) as run_doctor: + result = asyncio.run(tool.run(deep=True)) + + payload = json.loads(result) + assert payload["success"] is True + assert payload["deep"] is True + assert payload["include_details"] is True + assert payload["report"]["status"] == "degraded" + assert payload["report"]["environment"]["runtime"] == "Docker" + assert payload["report"]["findings"][0]["detail"] == "ERROR demo Cookie: " + run_doctor.assert_called_once_with(deep=True) + + +def test_query_doctor_report_compact_mode_omits_details(): + """紧凑模式应保留诊断项概要并省略 detail 和 context。""" + tool = QueryDoctorReportTool(session_id="doctor-session", user_id="10001") + + with patch( + "app.agent.tools.impl.query_doctor_report.run_doctor", + return_value=_doctor_report(), + ): + result = asyncio.run(tool.run(include_details=False)) + + payload = json.loads(result) + finding = payload["report"]["findings"][0] + assert finding["id"] == "logs.moviepilot.recent_errors" + assert finding["title"] == "最近日志存在错误线索" + assert "detail" not in finding + assert "context" not in finding + + +def test_mcp_tool_manager_exposes_doctor_report_tool(): + """MCP 工具管理器应暴露 doctor 诊断报告工具。""" + tool = QueryDoctorReportTool(session_id="doctor-session", user_id="10001") + + with patch( + "app.agent.tools.manager.MoviePilotToolFactory.create_tools", + return_value=[tool], + ): + manager = MoviePilotToolsManager(is_admin=True) + + tool_definitions = manager.list_tools() + assert [item.name for item in tool_definitions] == ["query_doctor_report"] + schema = tool_definitions[0].input_schema + assert "deep" in schema["properties"] + assert "include_details" in schema["properties"]