From b0ff3ae3c7fb3f68d29a9ea39915ab091331094e Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 19 Jul 2026 21:07:27 +0800 Subject: [PATCH] Fix Agent task relative-time validation (#6146) --- app/agent/tools/impl/create_agent_task.py | 27 ++++++++++++++++------- app/agent/tools/impl/update_agent_task.py | 25 ++++++++++++--------- tests/test_agent_scheduled_tasks.py | 24 +++++++++++++------- 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/app/agent/tools/impl/create_agent_task.py b/app/agent/tools/impl/create_agent_task.py index 9eca1cf2..ea373081 100644 --- a/app/agent/tools/impl/create_agent_task.py +++ b/app/agent/tools/impl/create_agent_task.py @@ -60,13 +60,12 @@ class CreateAgentTaskInput(BaseModel): 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") + # LangChain 会在 run() 前后各校验一次,延迟时间在持久化前统一计算。 + self.trigger = None + return self + if self.trigger is None: + raise ValueError("date 任务必须提供 trigger 或 delay_minutes") 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( @@ -102,6 +101,18 @@ class CreateAgentTaskTool(MoviePilotTool): """持久化任务并立即注册到运行时调度器。""" from app.scheduler import Scheduler + trigger_value = payload.trigger + if payload.trigger_type == "date" and payload.delay_minutes is not None: + timezone = pytz.timezone(settings.TZ) + trigger_value = ( + datetime.now(timezone) + timedelta(minutes=payload.delay_minutes) + ).isoformat(timespec="seconds") + _, trigger_value = TimerUtils.normalize_schedule_trigger( + trigger_type=payload.trigger_type, + trigger_value=trigger_value, + timezone_name=settings.TZ, + require_future=True, + ) chat = AgentChatOper().get( session_id=self._session_id, user_id=self._user_id, @@ -110,8 +121,8 @@ class CreateAgentTaskTool(MoviePilotTool): 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, + cron_expression=trigger_value if payload.trigger_type == "cron" else None, + run_at=trigger_value 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), diff --git a/app/agent/tools/impl/update_agent_task.py b/app/agent/tools/impl/update_agent_task.py index da44bfa6..72d196df 100644 --- a/app/agent/tools/impl/update_agent_task.py +++ b/app/agent/tools/impl/update_agent_task.py @@ -61,13 +61,11 @@ class UpdateAgentTaskInput(BaseModel): 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") + # 保持校验幂等,具体绝对时间在更新调度前只计算一次。 + self.trigger = None + elif self.trigger is None: + raise ValueError("date 任务必须提供 trigger 或 delay_minutes") elif self.trigger is None or self.delay_minutes is not None: raise ValueError("cron 任务必须提供 trigger,且不能提供 delay_minutes") if all( @@ -112,9 +110,16 @@ class UpdateAgentTaskTool(MoviePilotTool): 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 - ) + trigger_value = payload.trigger + if trigger_type == "date" and payload.delay_minutes is not None: + timezone = pytz.timezone(settings.TZ) + trigger_value = ( + datetime.now(timezone) + timedelta(minutes=payload.delay_minutes) + ).isoformat(timespec="seconds") + if trigger_value is None: + trigger_value = ( + 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, @@ -128,7 +133,7 @@ class UpdateAgentTaskTool(MoviePilotTool): update_payload["name"] = payload.name.strip() if payload.content is not None: update_payload["content"] = payload.content.strip() - if payload.trigger is not None: + if payload.trigger_type is not None: update_payload.update( { "trigger_type": normalized_type, diff --git a/tests/test_agent_scheduled_tasks.py b/tests/test_agent_scheduled_tasks.py index 45a8e181..dc9ddcbe 100644 --- a/tests/test_agent_scheduled_tasks.py +++ b/tests/test_agent_scheduled_tasks.py @@ -195,14 +195,12 @@ async def test_agent_task_tools_manage_persistent_schedule(monkeypatch) -> None: monkeypatch.setattr("app.scheduler.Scheduler", lambda: fake_scheduler) create_tool = _build_tool(CreateAgentTaskTool, user_id) - created = json.loads( - await create_tool.run( - name="十分钟后检查", - content="检查示例电影是否有资源,不要自动下载", - trigger_type="date", - delay_minutes=10, - ) - ) + created = json.loads(await create_tool.ainvoke({ + "name": "十分钟后检查", + "content": "检查示例电影是否有资源,不要自动下载", + "trigger_type": "date", + "delay_minutes": 10, + })) task_id = created["id"] assert created["enabled"] is True assert datetime.fromisoformat(created["run_at"]) > datetime.now( @@ -217,6 +215,16 @@ async def test_agent_task_tools_manage_persistent_schedule(monkeypatch) -> None: assert queried["tasks"][0]["content"].startswith("检查示例电影") update_tool = _build_tool(UpdateAgentTaskTool, user_id) + delayed_update = json.loads(await update_tool.ainvoke({ + "task_id": task_id, + "trigger_type": "date", + "delay_minutes": 20, + })) + assert delayed_update["trigger_type"] == "date" + assert datetime.fromisoformat(delayed_update["run_at"]) > datetime.now( + pytz.timezone(settings.TZ) + ) + updated = json.loads( await update_tool.run( task_id=task_id,