add: query_doctor_report agent tool

This commit is contained in:
jxxghp
2026-06-12 16:26:00 +08:00
parent 735a1ebf27
commit dfabd695a8
5 changed files with 242 additions and 2 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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: <REDACTED>",
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: <REDACTED>"
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"]