fix: implement tool execution timeout handling and improve blocking call management

This commit is contained in:
jxxghp
2026-06-12 08:43:17 +08:00
parent 765b286fd7
commit 1b83abe155
11 changed files with 296 additions and 45 deletions

View File

@@ -279,6 +279,15 @@ class MoviePilotAgent:
except (TypeError, ValueError):
return None
@staticmethod
def _get_recursion_limit() -> int:
"""读取 LangGraph 递归上限,防止模型持续循环调用工具。"""
try:
limit = int(settings.LLM_MAX_ITERATIONS or 0)
except (TypeError, ValueError):
limit = 0
return limit if limit > 0 else 128
@classmethod
def _get_model_name(cls, model: Any) -> Optional[str]:
return (
@@ -1024,7 +1033,8 @@ class MoviePilotAgent:
agent_config = {
"configurable": {
"thread_id": self.session_id,
}
},
"recursion_limit": self._get_recursion_limit(),
}
# 判断是否启用流式输出

View File

@@ -76,6 +76,7 @@ def format_tool_result_for_agent(
# 将常见的阻塞调用按能力域拆分到独立线程池,避免外部慢 IO 抢占同一批 worker。
_BLOCKING_BUCKET_LIMITS = {
"command": 4,
"default": 4,
"config": 2,
"db": 4,
@@ -86,6 +87,7 @@ _BLOCKING_BUCKET_LIMITS = {
"site": 4,
"storage": 4,
"subscribe": 2,
"web": 2,
"workflow": 2,
}
_blocking_semaphores = {
@@ -112,6 +114,54 @@ def _get_blocking_executor(bucket: str) -> ThreadPoolExecutor:
return executor
class ToolExecutionTimeoutError(TimeoutError):
"""Agent 工具执行超时异常。"""
def _get_tool_timeout_seconds() -> Optional[float]:
"""读取工具执行超时时间,配置为 0 或负数时表示不限制。"""
try:
timeout = float(settings.LLM_TOOL_TIMEOUT or 0)
except (TypeError, ValueError):
timeout = 0
return timeout if timeout > 0 else None
async def run_agent_blocking(
bucket: str, func: Callable[..., Any], *args: Any, **kwargs: Any
) -> Any:
"""
在受控线程池中运行阻塞型同步代码。
调用方被取消时不会提前释放并发名额,避免底层阻塞调用仍在运行时继续接纳
新任务,把同一类慢 IO 的线程池持续打满。
"""
bucket_name = bucket if bucket in _BLOCKING_BUCKET_LIMITS else "default"
semaphore = _blocking_semaphores[bucket_name]
bound_call = partial(func, *args, **kwargs)
loop = asyncio.get_running_loop()
await semaphore.acquire()
try:
future = _get_blocking_executor(bucket_name).submit(bound_call)
except Exception:
semaphore.release()
raise
def _release_semaphore(_future) -> None:
try:
_future.exception()
except Exception:
pass
try:
loop.call_soon_threadsafe(semaphore.release)
except RuntimeError:
pass
future.add_done_callback(_release_semaphore)
return await asyncio.shield(asyncio.wrap_future(future, loop=loop))
class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""
MoviePilot专用工具基类LangChain v1 / langchain_core
@@ -236,7 +286,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
# 执行具体工具逻辑
try:
result = await self.run(**kwargs)
result = await self.run_with_timeout(**kwargs)
# 记录工具执行结果摘要日志
str_result = serialize_tool_result_for_agent(result)
@@ -246,6 +296,10 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
summary = str_result
logger.info(f"Agent工具 {self.name} 执行完成,结果摘要: {summary}")
except ToolExecutionTimeoutError as e:
error_message = str(e)
logger.warning(error_message)
result = error_message
except Exception as e:
error_message = f"工具执行异常 ({type(e).__name__}): {str(e)}"
logger.error(f"Tool {self.name} execution failed: {e}", exc_info=True)
@@ -276,6 +330,18 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""子类实现具体的工具执行逻辑"""
raise NotImplementedError
async def run_with_timeout(self, **kwargs) -> str:
"""按系统配置限制单个工具调用的最长执行时间。"""
timeout = _get_tool_timeout_seconds()
if not timeout:
return await self.run(**kwargs)
try:
return await asyncio.wait_for(self.run(**kwargs), timeout=timeout)
except asyncio.TimeoutError as err:
raise ToolExecutionTimeoutError(
f"工具 {self.name} 执行超时(超过 {timeout:g} 秒),已停止等待结果。"
) from err
@staticmethod
async def run_blocking(
bucket: str, func: Callable[..., Any], *args: Any, **kwargs: Any
@@ -283,15 +349,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""
在受控线程池中运行阻塞型同步代码,避免拖住 FastAPI 主事件循环。
"""
bucket_name = bucket if bucket in _BLOCKING_BUCKET_LIMITS else "default"
semaphore = _blocking_semaphores[bucket_name]
bound_call = partial(func, *args, **kwargs)
async with semaphore:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
_get_blocking_executor(bucket_name), bound_call
)
return await run_agent_blocking(bucket, func, *args, **kwargs)
def set_message_attr(self, channel: str, source: str, username: str):
"""

View File

@@ -1,6 +1,5 @@
"""插件 Agent 工具共享辅助方法"""
import asyncio
import json
import shutil
from typing import Any, Optional
@@ -251,7 +250,9 @@ async def install_plugin_runtime(
SystemConfigKey.UserInstalledPlugins, install_plugins
)
await asyncio.to_thread(reload_plugin_runtime, plugin_id)
from app.agent.tools.base import run_agent_blocking
await run_agent_blocking("plugin", reload_plugin_runtime, plugin_id)
return True, message or "插件安装成功", refreshed_only

View File

@@ -1,6 +1,5 @@
"""浏览器操作工具 - 让Agent能够通过Playwright控制浏览器进行网页交互"""
import asyncio
import base64
import json
from enum import Enum
@@ -167,21 +166,18 @@ class BrowseWebpageTool(MoviePilotTool):
if browser_action == BrowserAction.EVALUATE and not script:
return "错误: 'evaluate' 操作需要提供 script 参数"
# 在线程池中运行同步的 Playwright 操作
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None,
lambda: self._execute_browser_action(
browser_action=browser_action,
url=url,
selector=selector,
value=value,
script=script,
content_type=content_type,
timeout=timeout,
cookies=cookies,
user_agent=user_agent,
),
result = await self.run_blocking(
"web",
self._execute_browser_action,
browser_action=browser_action,
url=url,
selector=selector,
value=value,
script=script,
content_type=content_type,
timeout=timeout,
cookies=cookies,
user_agent=user_agent,
)
return result

View File

@@ -451,6 +451,9 @@ class ExecuteCommandTool(MoviePilotTool):
except asyncio.TimeoutError:
timed_out = True
await self._cleanup_process(process, wait_task)
except asyncio.CancelledError:
await self._cleanup_process(process, wait_task)
raise
try:
await self._finish_reader_tasks(reader_tasks)

View File

@@ -1,4 +1,3 @@
import asyncio
import json
import re
from dataclasses import dataclass
@@ -445,8 +444,7 @@ class SearchWebTool(MoviePilotTool):
logger.warning(f"搜索引擎搜索进程失败: {err}")
return results
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(None, sync_search)
results = await self.run_blocking("web", sync_search)
return self._filter_results_by_site(results, site_filter)
except Exception as e:

View File

@@ -1,6 +1,4 @@
"""发送语音消息工具。"""
import asyncio
from typing import Optional, Type
from pydantic import BaseModel, Field
@@ -74,7 +72,8 @@ class SendVoiceMessageTool(MoviePilotTool):
reply_mode == AgentCapabilityManager.REPLY_MODE_NATIVE
and AgentCapabilityManager.is_audio_output_available()
):
voice_file = await asyncio.to_thread(
voice_file = await self.run_blocking(
"default",
AgentCapabilityManager.synthesize_speech, message
)
if voice_file:

View File

@@ -2,7 +2,7 @@ import json
import uuid
from typing import Any, Dict, List, Optional
from app.agent.tools.base import format_tool_result_for_agent
from app.agent.tools.base import ToolExecutionTimeoutError, format_tool_result_for_agent
from app.agent.tools.factory import MoviePilotToolFactory
from app.log import logger
@@ -259,10 +259,14 @@ class MoviePilotToolsManager:
# 调用工具的run方法。HTTP/MCP 工具调用不会经过 BaseTool._arun
# 因此这里也必须复用同一套返回值格式化和兜底截断逻辑。
result = await tool_instance.run(**normalized_arguments)
result = await tool_instance.run_with_timeout(**normalized_arguments)
# 记录工具执行结果摘要日志
str_result = format_tool_result_for_agent(result, tool_name=tool_name, max_chars=getattr(tool_instance, "result_max_chars", None))
str_result = format_tool_result_for_agent(
result,
tool_name=tool_name,
max_chars=getattr(tool_instance, "result_max_chars", None),
)
if len(str_result) > 500:
summary = str_result[:500] + f"...(已截断,总长度: {len(str_result)})"
else:
@@ -270,6 +274,13 @@ class MoviePilotToolsManager:
logger.info(f"Agent工具 {tool_name} 执行完成,结果摘要: {summary}")
return str_result
except ToolExecutionTimeoutError as e:
logger.warning(str(e))
return format_tool_result_for_agent(
str(e),
tool_name=tool_name,
max_chars=getattr(tool_instance, "result_max_chars", None),
)
except Exception as e:
logger.error(f"调用工具 {tool_name} 时发生错误: {e}", exc_info=True)
error_msg = json.dumps(