mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-22 00:32:50 +08:00
Add autonomous Agent task scheduling
This commit is contained in:
150
app/agent/tools/impl/create_agent_task.py
Normal file
150
app/agent/tools/impl/create_agent_task.py
Normal file
@@ -0,0 +1,150 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Literal, Optional, Type
|
||||
|
||||
import pytz
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.config import settings
|
||||
from app.db.agentchat_oper import AgentChatOper
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
from app.utils.timer import TimerUtils
|
||||
|
||||
|
||||
class CreateAgentTaskInput(BaseModel):
|
||||
"""创建 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Short task name shown in task management and execution reports.",
|
||||
)
|
||||
content: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=10000,
|
||||
description="Complete instructions that the agent must execute when the task fires.",
|
||||
)
|
||||
trigger_type: Literal["date", "cron"] = Field(
|
||||
...,
|
||||
description="Use 'date' for one exact future run or 'cron' for recurring work.",
|
||||
)
|
||||
trigger: Optional[str] = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
description=(
|
||||
"For date, an ISO 8601 local or timezone-aware time such as "
|
||||
"2026-07-19 20:30:00; for cron, a standard five-field expression "
|
||||
"(minute hour day month weekday). The MoviePilot system timezone is used."
|
||||
),
|
||||
)
|
||||
delay_minutes: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=525600,
|
||||
description=(
|
||||
"For a one-time date task expressed as 'in N minutes', provide this instead "
|
||||
"of trigger. MoviePilot calculates and persists the exact future run time."
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_trigger(self) -> "CreateAgentTaskInput":
|
||||
"""校验任务触发配置并统一格式。"""
|
||||
self.name = self.name.strip()
|
||||
self.content = self.content.strip()
|
||||
if not self.name or not self.content:
|
||||
raise ValueError("name 和 content 不能只包含空白字符")
|
||||
if self.trigger_type == "date":
|
||||
if (self.trigger is None) == (self.delay_minutes is None):
|
||||
raise ValueError("date 任务必须且只能提供 trigger 或 delay_minutes 之一")
|
||||
if self.delay_minutes is not None:
|
||||
timezone = pytz.timezone(settings.TZ)
|
||||
self.trigger = (
|
||||
datetime.now(timezone) + timedelta(minutes=self.delay_minutes)
|
||||
).isoformat(timespec="seconds")
|
||||
elif self.trigger is None or self.delay_minutes is not None:
|
||||
raise ValueError("cron 任务必须提供 trigger,且不能提供 delay_minutes")
|
||||
self.trigger_type, self.trigger = TimerUtils.normalize_schedule_trigger(
|
||||
trigger_type=self.trigger_type,
|
||||
trigger_value=self.trigger,
|
||||
timezone_name=settings.TZ,
|
||||
require_future=True,
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class CreateAgentTaskTool(MoviePilotTool):
|
||||
"""创建可精确唤醒当前 Agent 会话的自主定时任务。"""
|
||||
|
||||
name: str = "create_agent_task"
|
||||
tags: list[str] = [ToolTag.Write, ToolTag.Scheduler, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Create a persistent autonomous agent task only when the user explicitly asks "
|
||||
"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 "
|
||||
"trigger time for other one-time work, and 'cron' for recurring schedules. When "
|
||||
"fired, MoviePilot wakes the agent in this conversation, executes content, and "
|
||||
"sends the result to the user."
|
||||
)
|
||||
args_schema: Type[BaseModel] = CreateAgentTaskInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成创建定时任务的提示消息。"""
|
||||
return f"创建自主定时任务:{kwargs.get('name', '')}"
|
||||
|
||||
def _create_task(self, payload: CreateAgentTaskInput) -> dict:
|
||||
"""持久化任务并立即注册到运行时调度器。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
chat = AgentChatOper().get(
|
||||
session_id=self._session_id,
|
||||
user_id=self._user_id,
|
||||
)
|
||||
task = AgentTaskOper().add(
|
||||
name=payload.name.strip(),
|
||||
content=payload.content.strip(),
|
||||
trigger_type=payload.trigger_type,
|
||||
cron_expression=payload.trigger if payload.trigger_type == "cron" else None,
|
||||
run_at=payload.trigger if payload.trigger_type == "date" else None,
|
||||
user_id=str(self._user_id),
|
||||
username=self._username or (chat.username if chat else None),
|
||||
session_id=str(self._session_id),
|
||||
channel=self._channel or (chat.channel if chat else None),
|
||||
source=self._source or (chat.source if chat else None),
|
||||
original_chat_id=chat.original_chat_id if chat else None,
|
||||
)
|
||||
scheduler = Scheduler()
|
||||
next_run_at = scheduler.update_agent_task_job(task.id)
|
||||
return AgentTaskOper.to_dict(
|
||||
task,
|
||||
next_run_at=next_run_at,
|
||||
timezone=settings.TZ,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
name: str,
|
||||
content: str,
|
||||
trigger_type: str,
|
||||
trigger: Optional[str] = None,
|
||||
delay_minutes: Optional[int] = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""创建 Agent 自主定时任务。"""
|
||||
if not settings.AI_AGENT_ENABLE:
|
||||
return "AI Agent 未启用,无法创建自主定时任务"
|
||||
payload = CreateAgentTaskInput(
|
||||
name=name,
|
||||
content=content,
|
||||
trigger_type=trigger_type,
|
||||
trigger=trigger,
|
||||
delay_minutes=delay_minutes,
|
||||
)
|
||||
task = await self.run_blocking("db", self._create_task, payload)
|
||||
return json.dumps(task, ensure_ascii=False, indent=2)
|
||||
50
app/agent/tools/impl/delete_agent_task.py
Normal file
50
app/agent/tools/impl/delete_agent_task.py
Normal file
@@ -0,0 +1,50 @@
|
||||
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 DeleteAgentTaskInput(BaseModel):
|
||||
"""删除 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
task_id: int = Field(..., ge=1, description="ID of the task to permanently delete.")
|
||||
|
||||
|
||||
class DeleteAgentTaskTool(MoviePilotTool):
|
||||
"""永久删除 Agent 自主定时任务。"""
|
||||
|
||||
name: str = "delete_agent_task"
|
||||
tags: list[str] = [ToolTag.Write, ToolTag.Scheduler, ToolTag.Admin]
|
||||
description: str = (
|
||||
"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."
|
||||
)
|
||||
args_schema: Type[BaseModel] = DeleteAgentTaskInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成删除定时任务的提示消息。"""
|
||||
return f"删除自主定时任务:{kwargs.get('task_id', '')}"
|
||||
|
||||
def _delete_task(self, task_id: int) -> bool:
|
||||
"""删除当前用户的任务并移除运行时调度。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
deleted = AgentTaskOper().delete(
|
||||
task_id=task_id,
|
||||
user_id=str(self._user_id),
|
||||
)
|
||||
if deleted:
|
||||
Scheduler().remove_agent_task_job(task_id)
|
||||
return deleted
|
||||
|
||||
async def run(self, task_id: int, **kwargs: object) -> str:
|
||||
"""删除 Agent 自主定时任务。"""
|
||||
payload = DeleteAgentTaskInput(task_id=task_id)
|
||||
deleted = await self.run_blocking("db", self._delete_task, payload.task_id)
|
||||
if not deleted:
|
||||
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
|
||||
return f"Agent 定时任务 {task_id} 已删除"
|
||||
86
app/agent/tools/impl/query_agent_tasks.py
Normal file
86
app/agent/tools/impl/query_agent_tasks.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import json
|
||||
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.core.config import settings
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
|
||||
|
||||
class QueryAgentTasksInput(BaseModel):
|
||||
"""查询 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
task_id: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description="Optional task ID. Omit it to list tasks owned by the current user.",
|
||||
)
|
||||
enabled: Optional[bool] = Field(
|
||||
None,
|
||||
description="Optional enabled-state filter used when listing tasks.",
|
||||
)
|
||||
|
||||
|
||||
class QueryAgentTasksTool(MoviePilotTool):
|
||||
"""查询当前用户创建的 Agent 自主定时任务。"""
|
||||
|
||||
name: str = "query_agent_tasks"
|
||||
tags: list[str] = [ToolTag.Read, ToolTag.Scheduler, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Query persistent autonomous agent tasks, including their task content, exact "
|
||||
"date or cron trigger, enabled state, next run time, and latest execution result."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryAgentTasksInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成查询定时任务的提示消息。"""
|
||||
task_id = kwargs.get("task_id")
|
||||
return f"查询自主定时任务:{task_id}" if task_id else "查询自主定时任务"
|
||||
|
||||
def _query_tasks(
|
||||
self,
|
||||
task_id: Optional[int],
|
||||
enabled: Optional[bool],
|
||||
) -> list[dict]:
|
||||
"""读取当前用户的任务及运行时下一次触发时间。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
oper = AgentTaskOper()
|
||||
if task_id:
|
||||
task = oper.get(task_id=task_id, user_id=str(self._user_id))
|
||||
tasks = [task] if task else []
|
||||
else:
|
||||
tasks = oper.list(user_id=str(self._user_id), enabled=enabled)
|
||||
scheduler = Scheduler()
|
||||
result = []
|
||||
for task in tasks:
|
||||
data = oper.to_dict(
|
||||
task,
|
||||
next_run_at=scheduler.get_agent_task_next_run(task.id),
|
||||
timezone=settings.TZ,
|
||||
)
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
async def run(
|
||||
self,
|
||||
task_id: Optional[int] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""查询 Agent 自主定时任务。"""
|
||||
payload = QueryAgentTasksInput(task_id=task_id, enabled=enabled)
|
||||
tasks = await self.run_blocking(
|
||||
"db",
|
||||
self._query_tasks,
|
||||
payload.task_id,
|
||||
payload.enabled,
|
||||
)
|
||||
return json.dumps(
|
||||
{"total": len(tasks), "tasks": tasks},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
188
app/agent/tools/impl/update_agent_task.py
Normal file
188
app/agent/tools/impl/update_agent_task.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Literal, Optional, Type
|
||||
|
||||
import pytz
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.core.config import settings
|
||||
from app.db.agenttask_oper import AgentTaskOper
|
||||
from app.utils.timer import TimerUtils
|
||||
|
||||
|
||||
class UpdateAgentTaskInput(BaseModel):
|
||||
"""更新 Agent 自主定时任务的输入参数。"""
|
||||
|
||||
task_id: int = Field(..., ge=1, description="ID of the task to update.")
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
content: Optional[str] = Field(None, min_length=1, max_length=10000)
|
||||
trigger_type: Optional[Literal["date", "cron"]] = Field(
|
||||
None,
|
||||
description="New trigger type. Must be provided together with trigger.",
|
||||
)
|
||||
trigger: Optional[str] = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
description="New ISO 8601 date or five-field cron expression.",
|
||||
)
|
||||
delay_minutes: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=525600,
|
||||
description=(
|
||||
"For a one-time date task expressed as 'in N minutes', provide this instead "
|
||||
"of trigger together with trigger_type='date'."
|
||||
),
|
||||
)
|
||||
enabled: Optional[bool] = Field(
|
||||
None,
|
||||
description="Set false to pause the task or true to resume it.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_update(self) -> "UpdateAgentTaskInput":
|
||||
"""校验更新内容和触发参数组合。"""
|
||||
if self.name is not None:
|
||||
self.name = self.name.strip()
|
||||
if not self.name:
|
||||
raise ValueError("name 不能只包含空白字符")
|
||||
if self.content is not None:
|
||||
self.content = self.content.strip()
|
||||
if not self.content:
|
||||
raise ValueError("content 不能只包含空白字符")
|
||||
has_schedule_update = any(
|
||||
value is not None
|
||||
for value in (self.trigger_type, self.trigger, self.delay_minutes)
|
||||
)
|
||||
if has_schedule_update:
|
||||
if self.trigger_type is None:
|
||||
raise ValueError("修改触发配置时必须提供 trigger_type")
|
||||
if self.trigger_type == "date":
|
||||
if (self.trigger is None) == (self.delay_minutes is None):
|
||||
raise ValueError("date 任务必须且只能提供 trigger 或 delay_minutes 之一")
|
||||
if self.delay_minutes is not None:
|
||||
timezone = pytz.timezone(settings.TZ)
|
||||
self.trigger = (
|
||||
datetime.now(timezone) + timedelta(minutes=self.delay_minutes)
|
||||
).isoformat(timespec="seconds")
|
||||
elif self.trigger is None or self.delay_minutes is not None:
|
||||
raise ValueError("cron 任务必须提供 trigger,且不能提供 delay_minutes")
|
||||
if all(
|
||||
value is None
|
||||
for value in (
|
||||
self.name,
|
||||
self.content,
|
||||
self.trigger_type,
|
||||
self.enabled,
|
||||
)
|
||||
):
|
||||
raise ValueError("至少需要提供一个要更新的字段")
|
||||
return self
|
||||
|
||||
|
||||
class UpdateAgentTaskTool(MoviePilotTool):
|
||||
"""修改、暂停或恢复 Agent 自主定时任务。"""
|
||||
|
||||
name: str = "update_agent_task"
|
||||
tags: list[str] = [ToolTag.Write, ToolTag.Scheduler, ToolTag.Admin]
|
||||
description: str = (
|
||||
"Update an autonomous agent task's name, instructions, exact date or cron "
|
||||
"trigger, relative delay_minutes, or enabled state. Use enabled=false to pause "
|
||||
"and enabled=true to resume."
|
||||
)
|
||||
args_schema: Type[BaseModel] = UpdateAgentTaskInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs: object) -> Optional[str]:
|
||||
"""生成更新定时任务的提示消息。"""
|
||||
return f"更新自主定时任务:{kwargs.get('task_id', '')}"
|
||||
|
||||
def _update_task(self, payload: UpdateAgentTaskInput) -> Optional[dict]:
|
||||
"""更新当前用户的任务并刷新运行时调度。"""
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
oper = AgentTaskOper()
|
||||
task = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
|
||||
if not task:
|
||||
return None
|
||||
if task.last_status == "running":
|
||||
return {"error": f"Agent 定时任务 {payload.task_id} 正在执行,请稍后再修改"}
|
||||
|
||||
trigger_type = payload.trigger_type or task.trigger_type
|
||||
trigger_value = payload.trigger or (
|
||||
task.cron_expression if trigger_type == "cron" else task.run_at
|
||||
)
|
||||
enabled = task.enabled if payload.enabled is None else payload.enabled
|
||||
normalized_type, normalized_trigger = TimerUtils.normalize_schedule_trigger(
|
||||
trigger_type=trigger_type,
|
||||
trigger_value=trigger_value,
|
||||
timezone_name=settings.TZ,
|
||||
require_future=bool(enabled and trigger_type == "date"),
|
||||
)
|
||||
|
||||
update_payload = {}
|
||||
if payload.name is not None:
|
||||
update_payload["name"] = payload.name.strip()
|
||||
if payload.content is not None:
|
||||
update_payload["content"] = payload.content.strip()
|
||||
if payload.trigger is not None:
|
||||
update_payload.update(
|
||||
{
|
||||
"trigger_type": normalized_type,
|
||||
"cron_expression": (
|
||||
normalized_trigger if normalized_type == "cron" else None
|
||||
),
|
||||
"run_at": normalized_trigger if normalized_type == "date" else None,
|
||||
"last_status": "waiting",
|
||||
"last_result": None,
|
||||
}
|
||||
)
|
||||
if payload.enabled is not None:
|
||||
update_payload["enabled"] = payload.enabled
|
||||
if payload.enabled:
|
||||
update_payload["last_status"] = "waiting"
|
||||
|
||||
oper.update(
|
||||
task_id=payload.task_id,
|
||||
payload=update_payload,
|
||||
user_id=str(self._user_id),
|
||||
)
|
||||
scheduler = Scheduler()
|
||||
next_run_at = scheduler.update_agent_task_job(payload.task_id)
|
||||
updated_task = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
|
||||
return oper.to_dict(
|
||||
updated_task,
|
||||
next_run_at=next_run_at,
|
||||
timezone=settings.TZ,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
task_id: int,
|
||||
name: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
trigger_type: Optional[str] = None,
|
||||
trigger: Optional[str] = None,
|
||||
delay_minutes: Optional[int] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""更新 Agent 自主定时任务。"""
|
||||
payload = UpdateAgentTaskInput(
|
||||
task_id=task_id,
|
||||
name=name,
|
||||
content=content,
|
||||
trigger_type=trigger_type,
|
||||
trigger=trigger,
|
||||
delay_minutes=delay_minutes,
|
||||
enabled=enabled,
|
||||
)
|
||||
task = await self.run_blocking("db", self._update_task, payload)
|
||||
if not task:
|
||||
return f"Agent 定时任务 {task_id} 不存在或不属于当前用户"
|
||||
if task.get("error"):
|
||||
return task["error"]
|
||||
return json.dumps(task, ensure_ascii=False, indent=2)
|
||||
Reference in New Issue
Block a user