diff --git a/app/agent/api/executor.py b/app/agent/api/executor.py index cc8096b1a..8867341c1 100644 --- a/app/agent/api/executor.py +++ b/app/agent/api/executor.py @@ -15,6 +15,9 @@ from app.application.security.token import create_access_token from app.runtime.log import logger from app.runtime.settings import get_runtime_setting +_DEFAULT_API_REQUEST_TIMEOUT_SECONDS = 30.0 +_SEARCH_TORRENTS_REQUEST_TIMEOUT_SECONDS = 290.0 + class ApiExecutionError(RuntimeError): """固定 API 路由无法构造或请求失败。""" @@ -176,9 +179,14 @@ class MoviePilotApiExecutor: raise ApiExecutionError("GET operation 的 body 必须是 JSON 对象") query_data.update(body_data) body_data = None + request_timeout = ( + _SEARCH_TORRENTS_REQUEST_TIMEOUT_SECONDS + if operation_id == "search.torrents" + else _DEFAULT_API_REQUEST_TIMEOUT_SECONDS + ) request = self._request_factory( headers=self._build_headers(), - timeout=30, + timeout=request_timeout, verify=False, trust_env=False, ) diff --git a/app/agent/tools/base.py b/app/agent/tools/base.py index e34363148..8669ae959 100644 --- a/app/agent/tools/base.py +++ b/app/agent/tools/base.py @@ -611,9 +611,14 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta): """子类实现具体的工具执行逻辑""" raise NotImplementedError + def _get_run_timeout_seconds(self, **kwargs: Any) -> Optional[float]: + """获取本次工具调用的等待上限,默认沿用全局配置。""" + del kwargs + return _get_tool_timeout_seconds() + async def run_with_timeout(self, **kwargs) -> str: """按系统配置限制单个工具调用的最长执行时间。""" - timeout = _get_tool_timeout_seconds() + timeout = self._get_run_timeout_seconds(**kwargs) if not timeout: return await self.run(**kwargs) try: diff --git a/app/agent/tools/impl/api.py b/app/agent/tools/impl/api.py index 5a2c26fa9..26c4e477c 100644 --- a/app/agent/tools/impl/api.py +++ b/app/agent/tools/impl/api.py @@ -21,6 +21,7 @@ from app.schemas.types import NotificationChannel _TOOL_MESSAGE_OPERATION_MAX_CHARS = 96 _TOOL_MESSAGE_PARAMETER_MAX_CHARS = 320 +_SEARCH_TORRENTS_TOOL_TIMEOUT_SECONDS = 300.0 @lru_cache(maxsize=1) @@ -127,6 +128,12 @@ class MoviePilotApiTool(MoviePilotTool): ) return f"{message},主要参数:{parameter_summary}" + def _get_run_timeout_seconds(self, **kwargs: Any) -> Optional[float]: + """将 search.torrents 的总工具等待时间限制为五分钟。""" + if kwargs.get("operation_id") == "search.torrents": + return _SEARCH_TORRENTS_TOOL_TIMEOUT_SECONDS + return super()._get_run_timeout_seconds(**kwargs) + def get_mcp_input_schema(self) -> dict[str, Any]: """返回包含全部白名单 operation 精确参数的 MCP JSON Schema。""" return deepcopy(_load_api_mcp_input_schema()) diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 0bf64501e..ac9b4d39b 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -178,6 +178,8 @@ operation ID、权限、副作用、确认、恢复、结果敏感性及精确 只允许传 `tools/list` 对应 operation 分支中声明的 `path_params`、`query` 和 `body` 字段。不得传 URL、认证头、API Token 或任意 HTTP 方法。 +`search.torrents` 可能按媒体标题与别名分轮搜索站点:MoviePilot 内层请求最多等待 290 秒,Agent 工具总等待上限为 300 秒,预留 10 秒处理超时与返回结果;V3 MCP 包装层继续使用原有 `mcp_proxy_timeout` 配置(默认 600 秒)。其它 operation 沿用原有超时配置。`page` / `count` 只分页已完成的搜索结果,不会缩短站点搜索过程。 + 查询结果的兼容分页合同如下: - 原先返回完整列表、没有分页参数的接口会在端点签名、OpenAPI、Skill 和 MCP `oneOf` 中显式声明可选 `page` / `count`;`page` 必须不小于 1,`count` 范围为 1 到 200。两者都省略时仍返回原来的完整列表,不启用分页;显式传入任一参数时才分页,缺失的 `page` 按 1、缺失的 `count` 按 50 处理。FastAPI 的 `response` 注入对象不是业务输入,不会出现在 REST、Skill 或 MCP 参数中。 diff --git a/skills/moviepilot-api/api/search.md b/skills/moviepilot-api/api/search.md index 4dccf9572..011066f41 100644 --- a/skills/moviepilot-api/api/search.md +++ b/skills/moviepilot-api/api/search.md @@ -30,6 +30,7 @@ Purpose: Search torrent sites directly from a free-form title and optional media `GET /api/v1/search/media/{media_id}`; policy effect: `safe_read`. Purpose: Search torrent sites for one canonical media identity. - `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated. +- `execution`: Searches may run multiple title/alias rounds across configured sites. The inner Agent API request may wait up to 290 seconds and the Agent tool call is capped at 300 seconds, leaving 10 seconds for timeout handling and returning the result; the V3 MCP wrapper uses its existing configured timeout (600 seconds by default). `page` / `count` paginate the completed result and do not shorten site-search execution; do not issue a duplicate call while one is pending. - `path_params`: `media_id*` (string): Source-native media ID. Always pair it with the exact media_source returned by search. - `query`: `area` (string|null; default `title`): Optional region filter applied by the torrent search workflow.; `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `include_candidates` (boolean; default `False`): Include unconfirmed music resources and related albums for manual review. Defaults to false; candidates have no target media identity and must not be used for automatic download.; `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (string|null): MoviePilot media type or subscription-history category required by the operation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `season` (string|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (string|null): Exact site IDs included in the search or subscription scope. - `body`: none diff --git a/tests/test_agent_api_executor.py b/tests/test_agent_api_executor.py index 4b69c023f..83b3cf436 100644 --- a/tests/test_agent_api_executor.py +++ b/tests/test_agent_api_executor.py @@ -1,6 +1,7 @@ import asyncio import json from types import SimpleNamespace +from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch from app.agent.api.executor import ApiExecutionContext, MoviePilotApiExecutor @@ -82,6 +83,53 @@ def test_executor_keeps_non_collection_payload_unchanged_without_headers() -> No close.assert_awaited_once() +def _execute_and_get_request_timeout( + operation_id: str, + *, + path_params: Optional[dict] = None, + query: Optional[dict] = None, +) -> float: + """执行一个内存 API 请求并返回构造 HTTP 客户端时采用的超时。""" + response = SimpleNamespace( + status_code=200, + headers={}, + json=lambda: {"success": True}, + aclose=AsyncMock(), + ) + request_factory = MagicMock( + return_value=SimpleNamespace(request=AsyncMock(return_value=response)) + ) + executor = MoviePilotApiExecutor( + context=ApiExecutionContext(user_id="1", username="admin", is_admin=True), + request_factory=request_factory, + ) + + with patch("app.agent.api.executor.create_access_token", return_value="token"): + asyncio.run( + executor.execute( + operation_id, + path_params=path_params, + query=query, + ) + ) + + return request_factory.call_args.kwargs["timeout"] + + +def test_executor_extends_request_timeout_only_for_search_torrents() -> None: + """慢速种子搜索允许等待 290 秒,其它 operation 保持原有 30 秒。""" + assert _execute_and_get_request_timeout( + "search.torrents", + path_params={"media_id": "1423191"}, + query={"media_source": "themoviedb"}, + ) == 290 + assert _execute_and_get_request_timeout( + "search.title", + query={"keyword": "生化危机:爆发夜"}, + ) == 30 + assert _execute_and_get_request_timeout("subscription.list") == 30 + + def test_manager_default_session_id_is_http_header_compatible() -> None: """HTTP API 工具管理器生成的默认会话标识必须是可发送的字符串。""" manager = MoviePilotToolsManager(user_id="1") diff --git a/tests/test_agent_tool_timeouts.py b/tests/test_agent_tool_timeouts.py index 529dc0381..21e8bc631 100644 --- a/tests/test_agent_tool_timeouts.py +++ b/tests/test_agent_tool_timeouts.py @@ -14,6 +14,7 @@ from app.agent.tools.base import ( reopen_blocking_executors, shutdown_blocking_executors, ) +from app.agent.tools.impl.api import MoviePilotApiTool from app.agent.tools.manager import MoviePilotToolsManager from app.runtime.correlation import correlation_scope, get_correlation_id @@ -41,6 +42,16 @@ class BlockingAgentTool(MoviePilotTool): return "unused" +class SlowMoviePilotApiTool(MoviePilotApiTool): + """用于验证 search.torrents 的 operation 级等待上限。""" + + async def run(self, **kwargs) -> str: + """短暂等待以区分 search.torrents 与其它 operation 的超时。""" + del kwargs + await asyncio.sleep(0.03) + return "finished" + + @pytest.fixture(autouse=True) def _reset_blocking_executor_runtime(): """每个用例前后恢复阻塞池门禁,避免进程级 owner 状态串扰。""" @@ -76,6 +87,21 @@ def test_http_tool_manager_uses_same_timeout_guard(): assert "工具 slow_agent_tool 执行超时" in result +def test_moviepilot_api_extends_timeout_only_for_search_torrents(monkeypatch): + """search.torrents 可超过全局工具上限,其它 API operation 仍受原配置限制。""" + tool = SlowMoviePilotApiTool(session_id="session-1", user_id="10001") + monkeypatch.setattr("app.agent.tools.base._get_tool_timeout_seconds", lambda: 0.01) + assert tool._get_run_timeout_seconds(operation_id="search.torrents") == 300 + assert tool._get_run_timeout_seconds(operation_id="search.title") == 0.01 + + async def _run_calls(): + assert await tool.run_with_timeout(operation_id="search.torrents") == "finished" + with pytest.raises(ToolExecutionTimeoutError, match="超过 0.01 秒"): + await tool.run_with_timeout(operation_id="search.title") + + asyncio.run(_run_calls()) + + def test_run_blocking_keeps_bucket_slot_until_worker_finishes(): """被取消的阻塞调用在底层线程结束前不应释放同桶并发名额。""" tool = BlockingAgentTool(session_id="session-1", user_id="10001")