Route autonomous agent tasks through notification broadcasting

This commit is contained in:
jxxghp
2026-07-20 08:54:32 +08:00
parent 5588e37c6d
commit d666134ed2
20 changed files with 676 additions and 116 deletions
+23 -53
View File
@@ -1628,20 +1628,21 @@ class MoviePilotAgent:
if not streaming_stopped: if not streaming_stopped:
await self.stream_handler.stop_streaming() await self.stream_handler.stop_streaming()
async def send_agent_message(self, message: str, title: str = ""): async def send_agent_message(self, message: str, title: str = "") -> None:
""" """
通过原渠道发送消息给用户 发送 Agent 消息;后台任务不绑定原渠道,交由通知链广播。
""" """
broadcast = self.is_background
self._save_assistant_display_message_once(message) self._save_assistant_display_message_once(message)
await AgentChain().async_post_message( await AgentChain().async_post_message(
Notification( Notification(
channel=self.channel, channel=None if broadcast else self.channel,
source=self.source, source=None if broadcast else self.source,
mtype=NotificationType.Agent, mtype=NotificationType.Agent,
userid=self.user_id, userid=None if broadcast else self.user_id,
username=self.username, username=self.username or (settings.SUPERUSER if broadcast else None),
original_message_id=self.original_message_id, original_message_id=None if broadcast else self.original_message_id,
original_chat_id=self.original_chat_id, original_chat_id=None if broadcast else self.original_chat_id,
title=title, title=title,
text=message, text=message,
save_history=False, save_history=False,
@@ -2001,12 +2002,11 @@ class AgentManager:
else: else:
agent = self.active_agents[session_id] agent = self.active_agents[session_id]
agent.user_id = task.user_id agent.user_id = task.user_id
if task.channel: # 每条队列任务都携带完整消息上下文,None 也必须覆盖,避免后台任务
agent.channel = task.channel # 复用会话 Agent 时继续沿用上一条入站消息的渠道。
if task.source: agent.channel = task.channel
agent.source = task.source agent.source = task.source
if task.username: agent.username = task.username
agent.username = task.username
agent.original_message_id = task.original_message_id agent.original_message_id = task.original_message_id
agent.original_chat_id = task.original_chat_id agent.original_chat_id = task.original_chat_id
agent.reply_mode = task.reply_mode agent.reply_mode = task.reply_mode
@@ -2147,24 +2147,20 @@ class AgentManager:
f"任务内容:{task.content}\n\n" f"任务内容:{task.content}\n\n"
"完成后请直接向用户报告本次执行结果;如果无法完成,请说明原因。" "完成后请直接向用户报告本次执行结果;如果无法完成,请说明原因。"
) )
has_message_context = bool(task.channel and task.source)
success = True success = True
result = "" result = ""
notification_username = task.username or settings.SUPERUSER
try: try:
result = await self.process_message( result = await self.process_message(
session_id=task.session_id, session_id=task.session_id,
user_id=task.user_id, user_id=task.user_id,
message=task_message, message=task_message,
channel=task.channel, channel=None,
source=task.source, source=None,
username=task.username, username=notification_username,
original_chat_id=task.original_chat_id, original_chat_id=None,
reply_mode=( reply_mode=ReplyMode.DISPATCH,
ReplyMode.DISPATCH allow_message_tools=True,
if has_message_context
else ReplyMode.CAPTURE_ONLY
),
allow_message_tools=has_message_context,
wait_for_completion=True, wait_for_completion=True,
) )
result_text = str(result or "").strip() result_text = str(result or "").strip()
@@ -2175,47 +2171,21 @@ class AgentManager:
result = "定时任务已执行,但 Agent 未返回结果" result = "定时任务已执行,但 Agent 未返回结果"
await AgentChain().async_post_message( await AgentChain().async_post_message(
Notification( Notification(
channel=task.channel if has_message_context else None,
source=task.source if has_message_context else None,
mtype=NotificationType.Agent, mtype=NotificationType.Agent,
userid=task.user_id if has_message_context else None, username=notification_username,
username=(
task.username
if has_message_context
else settings.SUPERUSER
),
original_chat_id=task.original_chat_id,
title=f"定时任务:{task.name}", title=f"定时任务:{task.name}",
text=result, text=result,
save_history=False, save_history=False,
) )
) )
elif not has_message_context:
await AgentChain().async_post_message(
Notification(
mtype=NotificationType.Agent,
username=settings.SUPERUSER,
title=f"定时任务:{task.name}",
text=result_text,
save_history=False,
)
)
except Exception as err: except Exception as err:
success = False success = False
result = f"Agent 定时任务执行失败:{str(err)}" result = f"Agent 定时任务执行失败:{str(err)}"
logger.error(f"Agent 定时任务 {task_id} 执行失败: {str(err)}") logger.error(f"Agent 定时任务 {task_id} 执行失败: {str(err)}")
await AgentChain().async_post_message( await AgentChain().async_post_message(
Notification( Notification(
channel=task.channel if has_message_context else None,
source=task.source if has_message_context else None,
mtype=NotificationType.Agent, mtype=NotificationType.Agent,
userid=task.user_id if has_message_context else None, username=notification_username,
username=(
task.username
if has_message_context
else settings.SUPERUSER
),
original_chat_id=task.original_chat_id,
title=f"定时任务执行失败:{task.name}", title=f"定时任务执行失败:{task.name}",
text=result, text=result,
save_history=False, save_history=False,
+5 -2
View File
@@ -205,8 +205,11 @@ You have a scheduled jobs system for user-requested delayed or recurring work.
Rules: Rules:
- For new delayed, recurring, reminder, or monitoring work, use the dedicated - For new delayed, recurring, reminder, or monitoring work, use the dedicated
`create_agent_task`, `query_agent_tasks`, `update_agent_task`, and `create_agent_task`, `query_agent_tasks`, `update_agent_task`, `run_agent_task`,
`delete_agent_task` tools. Do not create or edit JOB.md files for new tasks. and `delete_agent_task` tools. These tools use integer task IDs. Do not create
or edit JOB.md files for new tasks.
- Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin,
or workflow runtime services; never pass their string job IDs to Agent task tools.
- Do not create tasks for immediate one-time work or work already handled by MoviePilot schedulers. - Do not create tasks for immediate one-time work or work already handled by MoviePilot schedulers.
- Entries listed above are legacy JOB.md tasks. Read their files only when a heartbeat asks you to execute them. - Entries listed above are legacy JOB.md tasks. Read their files only when a heartbeat asks you to execute them.
- During heartbeat checks, act only on `pending` or `in_progress` jobs, update status/last_run/logs, and leave recurring jobs `pending` after each run. - During heartbeat checks, act only on `pending` or `in_progress` jobs, update status/last_run/logs, and leave recurring jobs `pending` after each run.
+1 -1
View File
@@ -24,7 +24,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- Do not stop for approval on read-only operations. - Do not stop for approval on read-only operations.
- If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`. - If the user has not explicitly requested an operation that changes system behavior, ask for confirmation before proceeding. This includes modifying system settings, updating plugin configuration, reloading plugins, running restart/stop/start commands, or triggering slash commands such as `/restart`.
- Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services. - Always get explicit consent before destructive or high-impact actions such as starting downloads, deleting subscriptions, deleting download tasks or files, removing history, installing/uninstalling plugins, changing site authentication, changing scheduler or workflow execution state, restarting services, or stopping services.
- When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `create_agent_task` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks with `query_agent_tasks`, `update_agent_task`, and `delete_agent_task`. - When the user explicitly asks for delayed, recurring, reminder, or monitoring work, use `create_agent_task` instead of promising to remember it or writing a JOB.md file. Use a `date` trigger with `delay_minutes` for requests such as "in 30 minutes", an exact `date` trigger for other single future runs, and a five-field `cron` trigger for recurring work. Manage existing autonomous tasks with `query_agent_tasks`, `update_agent_task`, `run_agent_task`, and `delete_agent_task`; these tools use integer `task_id` values. Use `query_schedulers` and `run_scheduler` only for MoviePilot system, plugin, or workflow runtime services, whose string `job_id` values must never be passed to autonomous-task tools.
- If the user explicitly requested the exact write action, perform the smallest correct change and then validate the result. - If the user explicitly requested the exact write action, perform the smallest correct change and then validate the result.
- If a requested action is ambiguous between read-only inspection and state change, inspect first and ask a short confirmation question before the state-changing step. - If a requested action is ambiguous between read-only inspection and state change, inspect first and ask a short confirmation question before the state-changing step.
</confirmation_policy> </confirmation_policy>
+16 -1
View File
@@ -620,7 +620,8 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
发送工具通知消息。 发送工具通知消息。
WebAgent 渠道没有后端模块实例,前端流式面板通过 Agent 上下文中的 WebAgent 渠道没有后端模块实例,前端流式面板通过 Agent 上下文中的
回调直接接收通知;其它渠道继续走统一消息链。 回调直接接收通知;无渠道的后台任务清空渠道侧定位信息后交由消息链广播,
其它渠道继续走统一消息链。
""" """
callback = self._agent_context.get("notification_callback") callback = self._agent_context.get("notification_callback")
if ( if (
@@ -630,6 +631,20 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
callback(notification) callback(notification)
return return
if not self._channel or not self._source:
notification = notification.model_copy(
update={
"channel": None,
"source": None,
"userid": None,
"username": notification.username
or self._username
or settings.SUPERUSER,
"original_message_id": None,
"original_chat_id": None,
}
)
await ToolChain().async_post_message(notification) await ToolChain().async_post_message(notification)
async def send_tool_message( async def send_tool_message(
+2
View File
@@ -46,6 +46,7 @@ from app.agent.tools.impl.create_agent_task import CreateAgentTaskTool
from app.agent.tools.impl.delete_agent_task import DeleteAgentTaskTool from app.agent.tools.impl.delete_agent_task import DeleteAgentTaskTool
from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
from app.agent.tools.impl.run_agent_task import RunAgentTaskTool
from app.agent.tools.impl.run_scheduler import RunSchedulerTool from app.agent.tools.impl.run_scheduler import RunSchedulerTool
from app.agent.tools.impl.update_agent_task import UpdateAgentTaskTool from app.agent.tools.impl.update_agent_task import UpdateAgentTaskTool
from app.agent.tools.impl.query_workflows import QueryWorkflowsTool from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
@@ -148,6 +149,7 @@ class MoviePilotToolFactory:
CreateAgentTaskTool, CreateAgentTaskTool,
QueryAgentTasksTool, QueryAgentTasksTool,
UpdateAgentTaskTool, UpdateAgentTaskTool,
RunAgentTaskTool,
DeleteAgentTaskTool, DeleteAgentTaskTool,
QuerySchedulersTool, QuerySchedulersTool,
RunSchedulerTool, RunSchedulerTool,
+2 -2
View File
@@ -81,14 +81,14 @@ class CreateAgentTaskTool(MoviePilotTool):
"""创建可精确唤醒当前 Agent 会话的自主定时任务。""" """创建可精确唤醒当前 Agent 会话的自主定时任务。"""
name: str = "create_agent_task" name: str = "create_agent_task"
tags: list[str] = [ToolTag.Write, ToolTag.Scheduler, ToolTag.Admin] tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
description: str = ( description: str = (
"Create a persistent autonomous agent task only when the user explicitly asks " "Create a persistent autonomous agent task only when the user explicitly asks "
"for delayed, scheduled, recurring, reminder, or monitoring work. Use trigger_type " "for delayed, scheduled, recurring, reminder, or monitoring work. Use trigger_type "
"'date' with delay_minutes for requests such as 'check in 30 minutes', an exact " "'date' with delay_minutes for requests such as 'check in 30 minutes', an exact "
"trigger time for other one-time work, and 'cron' for recurring schedules. When " "trigger time for other one-time work, and 'cron' for recurring schedules. When "
"fired, MoviePilot wakes the agent in this conversation, executes content, and " "fired, MoviePilot wakes the agent in this conversation, executes content, and "
"sends the result to the user." "broadcasts user-facing messages through the configured notification channels."
) )
args_schema: Type[BaseModel] = CreateAgentTaskInput args_schema: Type[BaseModel] = CreateAgentTaskInput
require_admin: bool = True require_admin: bool = True
+1 -1
View File
@@ -17,7 +17,7 @@ class DeleteAgentTaskTool(MoviePilotTool):
"""永久删除 Agent 自主定时任务。""" """永久删除 Agent 自主定时任务。"""
name: str = "delete_agent_task" name: str = "delete_agent_task"
tags: list[str] = [ToolTag.Write, ToolTag.Scheduler, ToolTag.Admin] tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
description: str = ( description: str = (
"Permanently delete an autonomous agent task and remove its runtime schedule. " "Permanently delete an autonomous agent task and remove its runtime schedule. "
"Use update_agent_task with enabled=false when the user only wants to pause it." "Use update_agent_task with enabled=false when the user only wants to pause it."
+5 -3
View File
@@ -27,10 +27,12 @@ class QueryAgentTasksTool(MoviePilotTool):
"""查询当前用户创建的 Agent 自主定时任务。""" """查询当前用户创建的 Agent 自主定时任务。"""
name: str = "query_agent_tasks" name: str = "query_agent_tasks"
tags: list[str] = [ToolTag.Read, ToolTag.Scheduler, ToolTag.Admin] tags: list[str] = [ToolTag.Read, ToolTag.AgentTask, ToolTag.Admin]
description: str = ( description: str = (
"Query persistent autonomous agent tasks, including their task content, exact " "Query persistent autonomous agent tasks owned by the current user, including "
"date or cron trigger, enabled state, next run time, and latest execution result." "reminders, monitoring tasks, and recurring agent work. Returns the integer "
"task_id, instructions, trigger, enabled state, next run time, and latest result. "
"Do not use this for MoviePilot system, plugin, or workflow scheduler services."
) )
args_schema: Type[BaseModel] = QueryAgentTasksInput args_schema: Type[BaseModel] = QueryAgentTasksInput
require_admin: bool = True require_admin: bool = True
+45 -23
View File
@@ -3,7 +3,7 @@
import json import json
from typing import Optional, Type from typing import Optional, Type
from pydantic import BaseModel, Field from pydantic import BaseModel
from app.agent.tools.base import MoviePilotTool from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag from app.agent.tools.tags import ToolTag
@@ -11,47 +11,69 @@ from app.log import logger
class QuerySchedulersInput(BaseModel): class QuerySchedulersInput(BaseModel):
"""查询定时服务工具的输入参数模型""" """查询运行时定时服务的输入参数模型"""
class QuerySchedulersTool(MoviePilotTool): class QuerySchedulersTool(MoviePilotTool):
"""查询系统、插件和工作流注册的运行时定时服务。"""
name: str = "query_schedulers" name: str = "query_schedulers"
tags: list[str] = [ tags: list[str] = [
ToolTag.Read, ToolTag.Read,
ToolTag.Scheduler, ToolTag.Scheduler,
ToolTag.Admin,
] ]
description: str = "Query scheduled tasks and list all available scheduler jobs. Shows job status, next run time, and provider information." description: str = (
"Query runtime scheduler services registered by MoviePilot system components, "
"plugins, and workflows. It excludes user-created autonomous agent tasks; use "
"query_agent_tasks for reminders, monitoring tasks, and other agent schedules."
)
args_schema: Type[BaseModel] = QuerySchedulersInput args_schema: Type[BaseModel] = QuerySchedulersInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成友好的提示消息""" """生成查询运行时定时服务的提示消息"""
return "查询定时服务" return "查询系统定时服务"
async def run(self, **kwargs) -> str: async def run(self, **kwargs: object) -> str:
"""查询非 Agent 自主任务的运行时定时服务。"""
logger.info(f"执行工具: {self.name}") logger.info(f"执行工具: {self.name}")
try: try:
from app.scheduler import Scheduler from app.scheduler import AGENT_TASK_JOB_PREFIX, Scheduler
scheduler = Scheduler() scheduler = Scheduler()
schedulers = scheduler.list() agent_task_prefix = f"{AGENT_TASK_JOB_PREFIX}-"
schedulers = [
scheduler_item
for scheduler_item in scheduler.list()
if not str(scheduler_item.id or "").startswith(agent_task_prefix)
]
if schedulers: if schedulers:
# 转换为字典列表以便JSON序列化 schedulers_list = [
schedulers_list = [] {
for s in schedulers: "id": scheduler_item.id,
schedulers_list.append({ "name": scheduler_item.name,
"id": s.id, "provider": scheduler_item.provider,
"name": s.name, "status": scheduler_item.status,
"provider": s.provider, "next_run": scheduler_item.next_run,
"status": s.status, }
"next_run": s.next_run for scheduler_item in schedulers
}) ]
result_json = json.dumps(schedulers_list, ensure_ascii=False, indent=2) result_json = json.dumps(schedulers_list, ensure_ascii=False, indent=2)
# 限制最多30条结果
total_count = len(schedulers_list) total_count = len(schedulers_list)
if total_count > 30: if total_count > 30:
limited_schedulers = schedulers_list[:30] limited_schedulers = schedulers_list[:30]
limited_json = json.dumps(limited_schedulers, ensure_ascii=False, indent=2) limited_json = json.dumps(
return f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,仅显示前 30 条结果。\n\n{limited_json}" limited_schedulers,
ensure_ascii=False,
indent=2,
)
return (
f"注意:查询结果共找到 {total_count} 条,为节省上下文空间,"
f"仅显示前 30 条结果。\n\n{limited_json}"
)
return result_json return result_json
return "未找到定时服务" return "未找到系统、插件或工作流定时服务"
except Exception as e: except Exception as e:
logger.error(f"查询定时服务失败: {e}", exc_info=True) logger.error(f"查询定时服务失败: {e}", exc_info=True)
return f"查询定时服务时发生错误: {str(e)}" return f"查询定时服务时发生错误: {str(e)}"
+78
View File
@@ -0,0 +1,78 @@
"""立即执行 Agent 自主定时任务工具。"""
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.agenttask_oper import AgentTaskOper
class RunAgentTaskInput(BaseModel):
"""立即执行 Agent 自主定时任务的输入参数。"""
task_id: int = Field(
...,
ge=1,
description=(
"Integer autonomous task ID returned by query_agent_tasks. Do not pass a "
"runtime scheduler job_id such as agent-task-12."
),
)
class RunAgentTaskTool(MoviePilotTool):
"""将当前用户的 Agent 自主定时任务提交为立即执行。"""
name: str = "run_agent_task"
tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
description: str = (
"Queue an enabled autonomous agent task owned by the current user for immediate "
"execution. Use the integer task_id returned by query_agent_tasks. The task runs "
"after the current agent turn can finish and broadcasts its result through the "
"configured notification channels."
)
args_schema: Type[BaseModel] = RunAgentTaskInput
require_admin: bool = True
def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""生成立即执行 Agent 任务的提示消息。"""
return f"立即执行自主定时任务:{kwargs.get('task_id', '')}"
def _get_task_state(self, task_id: int) -> tuple[str, Optional[str]]:
"""校验任务归属和状态,返回可执行性及任务名称。"""
task = AgentTaskOper().get(
task_id=task_id,
user_id=str(self._user_id),
)
if not task:
return "not_found", None
if not task.enabled:
return "disabled", task.name
if task.last_status == "running":
return "running", task.name
return "ready", task.name
async def run(self, task_id: int, **kwargs: object) -> str:
"""立即执行当前用户拥有且已启用的 Agent 自主定时任务。"""
from app.scheduler import Scheduler
payload = RunAgentTaskInput(task_id=task_id)
status, task_name = await self.run_blocking(
"db",
self._get_task_state,
payload.task_id,
)
if status == "not_found":
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
if status == "disabled":
return f"Agent 定时任务 {task_id} 已暂停,请先恢复后再执行"
if status == "running":
return f"Agent 定时任务 {task_id} 正在执行,请勿重复触发"
if not Scheduler().start_agent_task(payload.task_id):
return f"Agent 定时任务 {task_id} 尚未注册到运行时调度器,无法立即执行"
return (
f"Agent 定时任务 {task_id} 已提交立即执行:{task_name}"
"执行完成后将通过已配置的通知渠道广播结果"
)
+22 -5
View File
@@ -14,23 +14,32 @@ class RunSchedulerInput(BaseModel):
job_id: str = Field( job_id: str = Field(
..., ...,
description="The ID of the scheduled job to run (can be obtained from query_schedulers tool)", description=(
"Runtime scheduler job ID returned by query_schedulers. Do not pass an "
"autonomous agent task ID or an agent-task-* runtime ID."
),
) )
class RunSchedulerTool(MoviePilotTool): class RunSchedulerTool(MoviePilotTool):
"""立即运行系统、插件或工作流注册的定时服务。"""
name: str = "run_scheduler" name: str = "run_scheduler"
tags: list[str] = [ tags: list[str] = [
ToolTag.Write, ToolTag.Write,
ToolTag.Scheduler, ToolTag.Scheduler,
ToolTag.Admin, ToolTag.Admin,
] ]
description: str = "Manually trigger a scheduled task to run immediately. This will execute the specified scheduler job by its ID." description: str = (
"Manually trigger a MoviePilot system, plugin, or workflow scheduler service by "
"the runtime job_id returned from query_schedulers. This tool does not run "
"user-created autonomous agent tasks; use run_agent_task with an integer task_id."
)
args_schema: Type[BaseModel] = RunSchedulerInput args_schema: Type[BaseModel] = RunSchedulerInput
require_admin: bool = True require_admin: bool = True
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs: object) -> Optional[str]:
"""根据运行参数生成友好的提示消息""" """根据运行参数生成友好的提示消息"""
job_id = kwargs.get("job_id", "") job_id = kwargs.get("job_id", "")
return f"运行定时服务 (ID: {job_id})" return f"运行定时服务 (ID: {job_id})"
@@ -46,10 +55,18 @@ class RunSchedulerTool(MoviePilotTool):
return True, scheduler_item.name return True, scheduler_item.name
return False, "" return False, ""
async def run(self, job_id: str, **kwargs) -> str: async def run(self, job_id: str, **kwargs: object) -> str:
"""立即运行非 Agent 自主任务的运行时定时服务。"""
logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}") logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}")
try: try:
from app.scheduler import AGENT_TASK_JOB_PREFIX
if job_id.startswith(f"{AGENT_TASK_JOB_PREFIX}-"):
return (
"Agent 自主定时任务不能通过 run_scheduler 运行,"
"请使用 query_agent_tasks 查询整数 task_id 后调用 run_agent_task"
)
job_exists, job_name = await self.run_blocking( job_exists, job_name = await self.run_blocking(
"workflow", self._run_scheduler_sync, job_id "workflow", self._run_scheduler_sync, job_id
) )
+1 -1
View File
@@ -85,7 +85,7 @@ class UpdateAgentTaskTool(MoviePilotTool):
"""修改、暂停或恢复 Agent 自主定时任务。""" """修改、暂停或恢复 Agent 自主定时任务。"""
name: str = "update_agent_task" name: str = "update_agent_task"
tags: list[str] = [ToolTag.Write, ToolTag.Scheduler, ToolTag.Admin] tags: list[str] = [ToolTag.Write, ToolTag.AgentTask, ToolTag.Admin]
description: str = ( description: str = (
"Update an autonomous agent task's name, instructions, exact date or cron " "Update an autonomous agent task's name, instructions, exact date or cron "
"trigger, relative delay_minutes, or enabled state. Use enabled=false to pause " "trigger, relative delay_minutes, or enabled state. Use enabled=false to pause "
+1
View File
@@ -25,6 +25,7 @@ class ToolTag(str, Enum):
Plugin = "plugin" Plugin = "plugin"
Workflow = "workflow" Workflow = "workflow"
Scheduler = "scheduler" Scheduler = "scheduler"
AgentTask = "agent_task"
File = "file" File = "file"
Directory = "directory" Directory = "directory"
Web = "web" Web = "web"
+3 -1
View File
@@ -219,7 +219,9 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
self.stream_handler = _WebAgentStreamingHandler(self._emit_output) self.stream_handler = _WebAgentStreamingHandler(self._emit_output)
def _should_stream(self) -> bool: def _should_stream(self) -> bool:
"""Web 面板需要实时输出,即使 Web 渠道本身不支持消息编辑""" """Web 对话实时输出,复用会话执行后台任务时改用非流式广播"""
if self.is_background:
return False
return True return True
def set_notification_callback( def set_notification_callback(
+15
View File
@@ -991,6 +991,21 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"""生成 Agent 自主定时任务的调度器 Job ID。""" """生成 Agent 自主定时任务的调度器 Job ID。"""
return f"{AGENT_TASK_JOB_PREFIX}-{task_id}" return f"{AGENT_TASK_JOB_PREFIX}-{task_id}"
def start_agent_task(self, task_id: int) -> bool:
"""
将指定 Agent 自主定时任务提交到运行时调度器立即执行。
:param task_id: Agent 自主定时任务 ID
:return: 任务存在且未运行时返回 True,否则返回 False
"""
job_id = self._get_agent_task_job_id(task_id)
with self._lock:
job = self._jobs.get(job_id)
if not job or job.get("running"):
return False
self.start(job_id)
return True
def init_agent_task_jobs(self) -> None: def init_agent_task_jobs(self) -> None:
""" """
从数据库恢复所有启用的 Agent 自主定时任务。 从数据库恢复所有启用的 Agent 自主定时任务。
+7 -2
View File
@@ -187,12 +187,17 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
| 工具 | 说明 | | 工具 | 说明 |
| :--- | :--- | | :--- | :--- |
| `create_agent_task` | 创建单次或周期任务,并保存任务内容及当前用户、会话和消息渠道 | | `create_agent_task` | 创建单次或周期任务,并保存任务内容及当前用户、会话上下文 |
| `query_agent_tasks` | 查询任务配置、启用状态、下次执行时间及最近执行结果 | | `query_agent_tasks` | 查询任务配置、启用状态、下次执行时间及最近执行结果 |
| `update_agent_task` | 修改任务内容或触发器,也可通过 `enabled` 暂停、恢复任务 | | `update_agent_task` | 修改任务内容或触发器,也可通过 `enabled` 暂停、恢复任务 |
| `run_agent_task` | 使用整数 `task_id` 将当前用户已启用的任务提交为立即执行 |
| `delete_agent_task` | 永久删除任务并立即移除运行时调度 | | `delete_agent_task` | 永久删除任务并立即移除运行时调度 |
`trigger_type=date` 表示单次执行:“30 分钟后检查”这类相对时间传 `delay_minutes=30`,由后端计算精确时间;固定时间则传 ISO 8601 `trigger`,支持精确到秒。`trigger_type=cron` 使用标准五段 cron(分、时、日、月、周),适合周期检查。未显式携带时区的时间按 MoviePilot 的 `TZ` 配置解释。任务由内存调度器精确触发,配置持久化到数据库,服务重启后会自动恢复;触发后 Agent 在原会话中执行 `content` 并把结果发送到原消息渠道。通过无会话上下文的 MCP/CLI 创建时,结果改发到 MoviePilot 已配置的管理员通知渠道。 `trigger_type=date` 表示单次执行:“30 分钟后检查”这类相对时间传 `delay_minutes=30`,由后端计算精确时间;固定时间则传 ISO 8601 `trigger`,支持精确到秒。`trigger_type=cron` 使用标准五段 cron(分、时、日、月、周),适合周期检查。未显式携带时区的时间按 MoviePilot 的 `TZ` 配置解释。任务由内存调度器精确触发,配置持久化到数据库,服务重启后会自动恢复;触发后 Agent 在原会话中执行 `content`,执行过程及最终结果均不绑定创建任务时的消息渠道,而是通过 MoviePilot 已配置的通知渠道广播。如果 Agent 在执行过程中已通过消息工具发送完整结果,任务结束时不会再次发送相同的最终回复
Agent 自主任务工具使用数据库中的整数 `task_id``query_schedulers``run_scheduler` 仅面向系统、插件和工作流注册的运行时定时服务,使用字符串 `job_id`,不会返回或执行 `agent-task-*`。两类 ID 不可混用;需要立即执行自主任务时,应先通过 `query_agent_tasks` 确认归属和状态,再调用 `run_agent_task`。立即执行只提交任务,不在当前工具调用内等待结果,从而避免同一 Agent 会话互相等待;执行结果仍按上述通知规则广播。
上述过滤只约束 Agent 工具,避免模型混用两类任务。前端系统设置和仪表盘使用的 `/api/v1/dashboard/schedule` 仍返回完整运行时列表,其中包含 `provider=[Agent]` 的自主任务;前端通过 `/api/v1/system/runscheduler` 立即执行这类列表项的行为也保持不变。
创建单次任务的参数示例: 创建单次任务的参数示例:
+17 -2
View File
@@ -1,6 +1,6 @@
--- ---
name: moviepilot-cli name: moviepilot-cli
version: 4 version: 6
description: >- description: >-
Use this skill when the user asks to operate MoviePilot through the local Use this skill when the user asks to operate MoviePilot through the local
`moviepilot tool` MCP CLI for normal product workflows: media search, torrent `moviepilot tool` MCP CLI for normal product workflows: media search, torrent
@@ -58,7 +58,7 @@ Always run `show <command>` before calling a command — parameter names are not
| Library | query_library_exists, query_library_latest, transfer_file, scrape_metadata, query_transfer_history | | Library | query_library_exists, query_library_latest, transfer_file, scrape_metadata, query_transfer_history |
| Files | list_directory, query_directory_settings | | Files | list_directory, query_directory_settings |
| Sites | query_sites, query_site_userdata, test_site, update_site, update_site_cookie | | Sites | query_sites, query_site_userdata, test_site, update_site, update_site_cookie |
| System | query_schedulers, run_scheduler, create_agent_task, query_agent_tasks, update_agent_task, delete_agent_task, query_workflows, run_workflow, query_rule_groups, query_episode_schedule, send_message | | System | query_schedulers, run_scheduler, create_agent_task, query_agent_tasks, update_agent_task, run_agent_task, delete_agent_task, query_workflows, run_workflow, query_rule_groups, query_episode_schedule, send_message |
## Workflows ## Workflows
@@ -192,6 +192,17 @@ Use autonomous tasks only when the user explicitly requests delayed, recurring,
reminder, or monitoring behavior. Immediate work should run directly. Use the reminder, or monitoring behavior. Immediate work should run directly. Use the
MoviePilot `TZ` setting for local times. MoviePilot `TZ` setting for local times.
Scheduled runs reuse the original Agent session context, but user-facing
messages are broadcast through MoviePilot's configured notification channels
instead of being tied to the channel that created the task. If the Agent sends
the complete result with a message tool during execution, it does not send the
same final reply again when the run finishes.
Autonomous task tools use the integer `task_id` returned by
`query_agent_tasks`. `query_schedulers` and `run_scheduler` are only for
MoviePilot system, plugin, and workflow runtime services and use string
`job_id` values; never mix these IDs or use those tools for autonomous tasks.
For a relative one-time request, use `date` with `delay_minutes`; MoviePilot For a relative one-time request, use `date` with `delay_minutes`; MoviePilot
calculates and persists the exact run time: calculates and persists the exact run time:
`moviepilot tool run create_agent_task name="检查电影资源" content="搜索电影《示例电影》是否有资源并报告,不要自动下载。" trigger_type=date delay_minutes=30` `moviepilot tool run create_agent_task name="检查电影资源" content="搜索电影《示例电影》是否有资源并报告,不要自动下载。" trigger_type=date delay_minutes=30`
@@ -209,6 +220,10 @@ List tasks and inspect `next_run_at` and the latest result:
Pause or resume a task: Pause or resume a task:
`moviepilot tool run update_agent_task task_id=1 enabled=false` `moviepilot tool run update_agent_task task_id=1 enabled=false`
Queue an enabled task for immediate execution without waiting in the current
Agent turn:
`moviepilot tool run run_agent_task task_id=1`
Delete a task only after confirming permanent removal with the user: Delete a task only after confirming permanent removal with the user:
`moviepilot tool run delete_agent_task task_id=1` `moviepilot tool run delete_agent_task task_id=1`
+416 -18
View File
@@ -1,14 +1,24 @@
import json import json
import threading import threading
from datetime import datetime, timedelta from datetime import datetime, timedelta
from unittest.mock import AsyncMock from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
import pytz import pytz
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from langchain_core.messages import AIMessage
from app.agent import AgentChain, AgentManager, ReplyMode from app.agent import (
AgentChain,
AgentManager,
MoviePilotAgent,
ReplyMode,
_MessageTask,
)
from app.agent.middleware.tool_selection import ToolSelectorMiddleware
from app.agent.tools.factory import MoviePilotToolFactory from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.create_agent_task import ( from app.agent.tools.impl.create_agent_task import (
CreateAgentTaskInput, CreateAgentTaskInput,
@@ -16,9 +26,15 @@ from app.agent.tools.impl.create_agent_task import (
) )
from app.agent.tools.impl.delete_agent_task import DeleteAgentTaskTool from app.agent.tools.impl.delete_agent_task import DeleteAgentTaskTool
from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool from app.agent.tools.impl.query_agent_tasks import QueryAgentTasksTool
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
from app.agent.tools.impl.run_agent_task import RunAgentTaskTool
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
from app.agent.tools.impl.send_message import SendMessageTool
from app.agent.tools.impl.update_agent_task import UpdateAgentTaskTool from app.agent.tools.impl.update_agent_task import UpdateAgentTaskTool
from app.agent.tools.tags import ToolTag
from app.core.config import settings from app.core.config import settings
from app.db.agenttask_oper import AgentTaskOper from app.db.agenttask_oper import AgentTaskOper
from app.schemas import ScheduleInfo
from app.scheduler import Scheduler from app.scheduler import Scheduler
from app.utils.timer import TimerUtils from app.utils.timer import TimerUtils
@@ -30,6 +46,7 @@ class _FakeAgentTaskScheduler:
"""初始化运行时调度记录。""" """初始化运行时调度记录。"""
self.updated = [] self.updated = []
self.removed = [] self.removed = []
self.started = []
def update_agent_task_job(self, task_id: int) -> str: def update_agent_task_job(self, task_id: int) -> str:
"""记录任务重载并返回固定的下一次执行时间。""" """记录任务重载并返回固定的下一次执行时间。"""
@@ -44,6 +61,11 @@ class _FakeAgentTaskScheduler:
"""返回固定的下一次执行时间。""" """返回固定的下一次执行时间。"""
return "2099-01-01T00:00:00+08:00" return "2099-01-01T00:00:00+08:00"
def start_agent_task(self, task_id: int) -> bool:
"""记录 Agent 任务立即执行投递。"""
self.started.append(task_id)
return True
@pytest.fixture @pytest.fixture
def anyio_backend() -> str: def anyio_backend() -> str:
@@ -114,10 +136,81 @@ def test_agent_task_tools_are_registered_with_relative_delay_schema() -> None:
"create_agent_task", "create_agent_task",
"query_agent_tasks", "query_agent_tasks",
"update_agent_task", "update_agent_task",
"run_agent_task",
"delete_agent_task", "delete_agent_task",
}.issubset(tool_names) }.issubset(tool_names)
agent_task_tool_names = {
"create_agent_task",
"query_agent_tasks",
"update_agent_task",
"run_agent_task",
"delete_agent_task",
}
assert [
tool_class.model_fields["name"].default
for tool_class in MoviePilotToolFactory.BUILTIN_TOOL_CLASSES
if tool_class.model_fields["name"].default in agent_task_tool_names
] == [
"create_agent_task",
"query_agent_tasks",
"update_agent_task",
"run_agent_task",
"delete_agent_task",
]
assert "delay_minutes" in CreateAgentTaskInput.model_json_schema()["properties"] assert "delay_minutes" in CreateAgentTaskInput.model_json_schema()["properties"]
agent_task_tools = [
_build_tool(tool_class, "admin-user")
for tool_class in (
CreateAgentTaskTool,
QueryAgentTasksTool,
UpdateAgentTaskTool,
RunAgentTaskTool,
DeleteAgentTaskTool,
)
]
assert all(ToolTag.AgentTask.value in tool.tags for tool in agent_task_tools)
assert all(ToolTag.Scheduler.value not in tool.tags for tool in agent_task_tools)
scheduler_tool = _build_tool(QuerySchedulersTool, "admin-user")
assert ToolTag.Scheduler.value in scheduler_tool.tags
assert ToolTag.AgentTask.value not in scheduler_tool.tags
runtime_scheduler_tools = [
scheduler_tool,
_build_tool(RunSchedulerTool, "admin-user"),
]
all_scheduler_tools = [*agent_task_tools, *runtime_scheduler_tools]
tool_groups = dict(
ToolSelectorMiddleware._build_tool_groups(
available_tools=all_scheduler_tools,
valid_tool_names=[tool.name for tool in all_scheduler_tools],
)
)
assert tool_groups[ToolTag.AgentTask.value] == [
"create_agent_task",
"query_agent_tasks",
"update_agent_task",
"run_agent_task",
"delete_agent_task",
]
assert tool_groups[ToolTag.Scheduler.value] == [
"query_schedulers",
"run_scheduler",
]
def test_agent_prompt_declares_scheduler_tool_boundaries() -> None:
"""核心提示应明确自主任务与运行时调度服务的工具和 ID 边界。"""
project_root = Path(__file__).resolve().parents[1]
core_prompt = (project_root / "app/agent/prompt/System Core Prompt.txt").read_text(
encoding="utf-8"
)
assert "Manage existing autonomous tasks with `query_agent_tasks`" in core_prompt
assert "`run_agent_task`" in core_prompt
assert "Use `query_schedulers` and `run_scheduler` only" in core_prompt
assert "integer `task_id`" in core_prompt
assert "string `job_id`" in core_prompt
def test_agent_task_oper_persists_and_scopes_tasks() -> None: def test_agent_task_oper_persists_and_scopes_tasks() -> None:
"""AgentTaskOper 应持久化任务并按创建用户隔离查询和修改。""" """AgentTaskOper 应持久化任务并按创建用户隔离查询和修改。"""
@@ -155,7 +248,7 @@ def test_agent_task_oper_persists_and_scopes_tasks() -> None:
def test_scheduler_registers_and_removes_agent_task_job() -> None: def test_scheduler_registers_and_removes_agent_task_job() -> None:
"""Scheduler 应把数据库任务注册为精确 APScheduler Job 并可动态移除。""" """Scheduler 应注册并完整列出 Agent 任务,供前端展示和动态移除。"""
user_id = f"user-{uuid4().hex}" user_id = f"user-{uuid4().hex}"
task = AgentTaskOper().add( task = AgentTaskOper().add(
name="定时检查", name="定时检查",
@@ -181,10 +274,111 @@ def test_scheduler_registers_and_removes_agent_task_job() -> None:
assert next_run_at assert next_run_at
assert scheduler._scheduler.get_job(job_id) is not None assert scheduler._scheduler.get_job(job_id) is not None
assert scheduler._jobs[job_id]["kwargs"] == {"task_id": task.id} assert scheduler._jobs[job_id]["kwargs"] == {"task_id": task.id}
scheduler._scheduler.start(paused=True)
try:
scheduler_items = scheduler.list()
agent_scheduler_item = next(
item for item in scheduler_items if item.id == job_id
)
assert agent_scheduler_item.name == task.name
assert agent_scheduler_item.provider == "[Agent]"
scheduler.remove_agent_task_job(task.id) scheduler.remove_agent_task_job(task.id)
assert scheduler._scheduler.get_job(job_id) is None assert scheduler._scheduler.get_job(job_id) is None
assert job_id not in scheduler._jobs assert job_id not in scheduler._jobs
finally:
scheduler._scheduler.shutdown(wait=False)
def test_scheduler_starts_registered_agent_task_without_waiting() -> None:
"""Agent 任务立即执行入口应只调用运行时 start,并拒绝缺失或运行中的任务。"""
scheduler = object.__new__(Scheduler)
scheduler._lock = threading.RLock()
scheduler._jobs = {
"agent-task-7": {
"name": "测试 Agent 任务",
"running": False,
}
}
scheduler.start = Mock()
assert scheduler.start_agent_task(7) is True
scheduler.start.assert_called_once_with("agent-task-7")
scheduler._jobs["agent-task-7"]["running"] = True
assert scheduler.start_agent_task(7) is False
assert scheduler.start_agent_task(8) is False
@pytest.mark.anyio
async def test_dashboard_schedule_keeps_agent_tasks(monkeypatch) -> None:
"""前端后台服务接口必须保留 Agent 自主任务。"""
from app.api.endpoints.dashboard import schedule
scheduler_items = [
ScheduleInfo(
id="agent-task-7",
name="检查资源",
provider="[Agent]",
status="等待",
next_run="20 分钟后",
)
]
monkeypatch.setattr(
"app.api.endpoints.dashboard.Scheduler",
lambda: SimpleNamespace(list=lambda: scheduler_items),
)
result = await schedule(None)
assert result == scheduler_items
assert result[0].id == "agent-task-7"
assert result[0].provider == "[Agent]"
@pytest.mark.anyio
async def test_scheduler_tools_exclude_agent_tasks(monkeypatch) -> None:
"""运行时调度查询应过滤 Agent 任务,并把两类查询边界写入工具描述。"""
scheduler = SimpleNamespace(
list=lambda: [
ScheduleInfo(
id="subscribe_search_all",
name="订阅搜索",
provider="[系统]",
status="等待",
next_run="10 分钟后",
),
ScheduleInfo(
id="agent-task-7",
name="检查资源",
provider="[Agent]",
status="等待",
next_run="20 分钟后",
),
]
)
monkeypatch.setattr("app.scheduler.Scheduler", lambda: scheduler)
tool = _build_tool(QuerySchedulersTool, "admin-user")
result = json.loads(await tool.run())
assert [item["id"] for item in result] == ["subscribe_search_all"]
assert tool.require_admin is True
assert "excludes user-created autonomous agent tasks" in tool.description
agent_query_tool = _build_tool(QueryAgentTasksTool, "admin-user")
assert "owned by the current user" in agent_query_tool.description
@pytest.mark.anyio
async def test_run_scheduler_rejects_agent_task_job_id() -> None:
"""run_scheduler 不应接受 Agent 任务的运行时 job_id。"""
tool = _build_tool(RunSchedulerTool, "admin-user")
tool._run_scheduler_sync = Mock()
result = await tool.run(job_id="agent-task-7")
assert "run_agent_task" in result
tool._run_scheduler_sync.assert_not_called()
@pytest.mark.anyio @pytest.mark.anyio
@@ -243,14 +437,60 @@ async def test_agent_task_tools_manage_persistent_schedule(monkeypatch) -> None:
assert running_update == f"Agent 定时任务 {task_id} 正在执行,请稍后再修改" assert running_update == f"Agent 定时任务 {task_id} 正在执行,请稍后再修改"
AgentTaskOper().finish(task_id, success=True, result="完成") AgentTaskOper().finish(task_id, success=True, result="完成")
run_result = await _build_tool(RunAgentTaskTool, user_id).run(task_id=task_id)
assert f"Agent 定时任务 {task_id} 已提交立即执行" in run_result
assert fake_scheduler.started == [task_id]
deleted = await _build_tool(DeleteAgentTaskTool, user_id).run(task_id=task_id) deleted = await _build_tool(DeleteAgentTaskTool, user_id).run(task_id=task_id)
assert deleted == f"Agent 定时任务 {task_id} 已删除" assert deleted == f"Agent 定时任务 {task_id} 已删除"
assert fake_scheduler.removed == [task_id] assert fake_scheduler.removed == [task_id]
@pytest.mark.anyio @pytest.mark.anyio
async def test_agent_manager_executes_task_in_original_session() -> None: async def test_run_agent_task_enforces_owner_and_enabled_state(monkeypatch) -> None:
"""定时触发应复用原 Agent 会话和渠道,并在单次执行后停用任务""" """立即执行 Agent 任务时应校验当前用户归属和启用状态"""
owner_id = f"owner-{uuid4().hex}"
task = AgentTaskOper().add(
name="检查资源",
content="检查资源并报告",
trigger_type="cron",
cron_expression="0 * * * *",
run_at=None,
user_id=owner_id,
username="admin",
session_id=f"session-{owner_id}",
channel="Telegram",
source="telegram-test",
original_chat_id="chat-1",
)
fake_scheduler = _FakeAgentTaskScheduler()
monkeypatch.setattr("app.scheduler.Scheduler", lambda: fake_scheduler)
other_user_result = await _build_tool(
RunAgentTaskTool,
"another-user",
).run(task_id=task.id)
assert "不存在或不属于当前用户" in other_user_result
assert fake_scheduler.started == []
AgentTaskOper().update(
task_id=task.id,
user_id=owner_id,
payload={"enabled": False},
)
disabled_result = await _build_tool(
RunAgentTaskTool,
owner_id,
).run(task_id=task.id)
assert "已暂停" in disabled_result
assert fake_scheduler.started == []
@pytest.mark.anyio
async def test_agent_manager_executes_task_with_broadcast_delivery(
monkeypatch,
) -> None:
"""定时触发应复用原 Agent 会话、广播结果并在单次执行后停用任务。"""
user_id = f"user-{uuid4().hex}" user_id = f"user-{uuid4().hex}"
task = AgentTaskOper().add( task = AgentTaskOper().add(
name="检查电影资源", name="检查电影资源",
@@ -267,6 +507,8 @@ async def test_agent_manager_executes_task_in_original_session() -> None:
) )
manager = AgentManager() manager = AgentManager()
manager.process_message = AsyncMock(return_value="已找到 2 个资源") manager.process_message = AsyncMock(return_value="已找到 2 个资源")
post_message = AsyncMock()
monkeypatch.setattr(AgentChain, "async_post_message", post_message)
success, result = await manager.execute_scheduled_task(task.id) success, result = await manager.execute_scheduled_task(task.id)
@@ -275,12 +517,14 @@ async def test_agent_manager_executes_task_in_original_session() -> None:
kwargs = manager.process_message.await_args.kwargs kwargs = manager.process_message.await_args.kwargs
assert kwargs["session_id"] == task.session_id assert kwargs["session_id"] == task.session_id
assert kwargs["user_id"] == user_id assert kwargs["user_id"] == user_id
assert kwargs["channel"] == "Telegram" assert kwargs["channel"] is None
assert kwargs["source"] == "telegram-test" assert kwargs["source"] is None
assert kwargs["original_chat_id"] == "chat-123" assert kwargs["original_chat_id"] is None
assert kwargs["reply_mode"] == ReplyMode.DISPATCH assert kwargs["reply_mode"] == ReplyMode.DISPATCH
assert kwargs["allow_message_tools"] is True
assert kwargs["wait_for_completion"] is True assert kwargs["wait_for_completion"] is True
assert "搜索示例电影是否已有资源" in kwargs["message"] assert "搜索示例电影是否已有资源" in kwargs["message"]
post_message.assert_not_awaited()
completed = AgentTaskOper().get(task.id) completed = AgentTaskOper().get(task.id)
assert completed.enabled is False assert completed.enabled is False
@@ -312,10 +556,10 @@ async def test_agent_manager_executes_task_in_original_session() -> None:
@pytest.mark.anyio @pytest.mark.anyio
async def test_agent_manager_dispatches_contextless_result_to_admin( async def test_agent_manager_runs_contextless_task_in_broadcast_mode(
monkeypatch, monkeypatch,
) -> None: ) -> None:
"""无原消息渠道的 MCP 任务应捕获结果并发送到管理员通知渠道""" """无原消息渠道的任务也应由 Agent 广播结果且不由调度器重复补发"""
user_id = f"api-{uuid4().hex}" user_id = f"api-{uuid4().hex}"
task = AgentTaskOper().add( task = AgentTaskOper().add(
name="后台检查资源", name="后台检查资源",
@@ -340,9 +584,163 @@ async def test_agent_manager_dispatches_contextless_result_to_admin(
assert success is True assert success is True
assert result == "后台检查完成" assert result == "后台检查完成"
kwargs = manager.process_message.await_args.kwargs kwargs = manager.process_message.await_args.kwargs
assert kwargs["reply_mode"] == ReplyMode.CAPTURE_ONLY assert kwargs["channel"] is None
assert kwargs["allow_message_tools"] is False assert kwargs["source"] is None
assert kwargs["reply_mode"] == ReplyMode.DISPATCH
assert kwargs["allow_message_tools"] is True
post_message.assert_not_awaited()
@pytest.mark.anyio
async def test_agent_manager_broadcasts_empty_task_result(monkeypatch) -> None:
"""Agent 未返回内容时,调度器应广播一次兜底消息。"""
user_id = f"empty-{uuid4().hex}"
task = AgentTaskOper().add(
name="空结果检查",
content="执行检查",
trigger_type="cron",
cron_expression="0 * * * *",
run_at=None,
user_id=user_id,
username="admin",
session_id=f"session-{user_id}",
channel="Telegram",
source="telegram-test",
original_chat_id="chat-123",
)
manager = AgentManager()
manager.process_message = AsyncMock(return_value="")
post_message = AsyncMock()
monkeypatch.setattr(AgentChain, "async_post_message", post_message)
success, result = await manager.execute_scheduled_task(task.id)
assert success is False
assert result == "定时任务已执行,但 Agent 未返回结果"
notification = post_message.await_args.args[0] notification = post_message.await_args.args[0]
assert notification.channel is None
assert notification.source is None
assert notification.userid is None assert notification.userid is None
assert notification.username == settings.SUPERUSER assert notification.original_chat_id is None
assert notification.text == "后台检查完成" assert notification.username == "admin"
@pytest.mark.anyio
async def test_cached_agent_clears_channel_for_background_task() -> None:
"""复用会话 Agent 时,后台任务必须覆盖上一轮保留的渠道信息。"""
manager = AgentManager()
agent = MoviePilotAgent(
session_id="scheduled-cached-session",
user_id="user-1",
channel="Telegram",
source="telegram-test",
username="admin",
original_chat_id="chat-123",
)
agent.process = AsyncMock(return_value="完成")
manager.active_agents[agent.session_id] = agent
task = _MessageTask(
session_id=agent.session_id,
user_id="user-1",
message="执行后台任务",
channel=None,
source=None,
username="admin",
original_chat_id=None,
reply_mode=ReplyMode.DISPATCH,
allow_message_tools=True,
)
result = await manager._process_message_internal(task)
assert result == "完成"
assert agent.channel is None
assert agent.source is None
assert agent.original_chat_id is None
@pytest.mark.anyio
async def test_background_agent_final_message_is_broadcast() -> None:
"""后台 Agent 的最终消息应清空渠道及渠道用户定位后广播。"""
agent = MoviePilotAgent(
session_id="scheduled-broadcast-session",
user_id="telegram-user-id",
channel=None,
source=None,
username="admin",
original_message_id="message-1",
original_chat_id="chat-1",
)
with patch(
"app.agent.AgentChain.async_post_message",
new_callable=AsyncMock,
) as post_message:
await agent.send_agent_message("任务完成", title="MoviePilot助手")
notification = post_message.await_args.args[0]
assert notification.channel is None
assert notification.source is None
assert notification.userid is None
assert notification.original_message_id is None
assert notification.original_chat_id is None
assert notification.username == "admin"
@pytest.mark.anyio
async def test_background_send_message_tool_broadcasts() -> None:
"""后台 send_message 工具应广播消息并记录本轮已经回复。"""
tool = SendMessageTool(
session_id="scheduled-tool-session",
user_id="telegram-user-id",
)
tool.set_message_attr(channel=None, source=None, username="admin")
agent_context = {}
tool.set_agent_context(agent_context)
with patch(
"app.agent.tools.base.ToolChain.async_post_message",
new_callable=AsyncMock,
) as post_message:
result = await tool.run(message="工具已完成任务")
assert result == "消息已发送"
assert agent_context["user_reply_sent"] is True
notification = post_message.await_args.args[0]
assert notification.channel is None
assert notification.source is None
assert notification.userid is None
assert notification.original_message_id is None
assert notification.original_chat_id is None
assert notification.username == "admin"
@pytest.mark.anyio
async def test_background_agent_does_not_repeat_tool_message() -> None:
"""消息工具已完成回复后,后台 Agent 不应再次发送最终文本。"""
agent = MoviePilotAgent(
session_id="scheduled-no-repeat-session",
user_id="user-1",
channel=None,
source=None,
username="admin",
replay_mode=ReplyMode.DISPATCH,
)
agent._tool_context = {"user_reply_sent": True}
agent._streamed_output = ""
agent.stream_handler = SimpleNamespace(
stop_streaming=AsyncMock(return_value=(False, ""))
)
agent._should_stream = lambda: False
completed_agent = SimpleNamespace(
ainvoke=AsyncMock(return_value=None),
get_state=lambda _config: SimpleNamespace(
values={"messages": [AIMessage(content="消息已发送")]}
),
)
agent._create_agent = AsyncMock(return_value=completed_agent)
agent.send_agent_message = AsyncMock()
await agent._execute_agent([])
agent.send_agent_message.assert_not_awaited()
+1 -1
View File
@@ -23,7 +23,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
expected_versions = { expected_versions = {
"database-operation": "3", "database-operation": "3",
"moviepilot-api": "2", "moviepilot-api": "2",
"moviepilot-cli": "4", "moviepilot-cli": "6",
"moviepilot-update": "3", "moviepilot-update": "3",
} }
+15
View File
@@ -296,6 +296,21 @@ def test_web_agent_admin_context_uses_current_user_id():
user_oper.return_value.async_get_by_id.assert_awaited_once_with(7) user_oper.return_value.async_get_by_id.assert_awaited_once_with(7)
def test_web_agent_reused_for_background_task_disables_streaming():
"""Web Agent 被后台任务复用且渠道已清空时应改用非流式广播。"""
agent = _WebAgentMoviePilotAgent(
session_id="web-agent:scheduled-session",
user_id="7",
channel=None,
source=None,
username="admin",
replay_mode=ReplyMode.DISPATCH,
)
assert agent.is_background is True
assert agent._should_stream() is False
def test_web_agent_channel_supports_streaming_and_attachments(): def test_web_agent_channel_supports_streaming_and_attachments():
"""WebAgent 渠道应声明流式、多媒体和文件发送能力。""" """WebAgent 渠道应声明流式、多媒体和文件发送能力。"""
assert ChannelCapabilityManager.supports_capability( assert ChannelCapabilityManager.supports_capability(