From c791c815e1632dc77ee0202887ad67f270fd5d24 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:32:57 +0800 Subject: [PATCH] perf: merge 3 cron tools into 1 cron manage tool, and add edit capability for cron tool. (#7445) * perf: replace cron tools with FutureTaskTool for improved task management * feat: enhance FutureTaskTool with edit functionality and improve descriptions * feat: add edit functionality for cron jobs and update related UI components --- astrbot/core/astr_main_agent.py | 10 +- astrbot/core/tools/cron_tools.py | 302 ++++++++++-------- astrbot/dashboard/routes/cron.py | 137 +++++++- .../src/i18n/locales/en-US/features/cron.json | 4 + .../src/i18n/locales/ru-RU/features/cron.json | 4 + .../src/i18n/locales/zh-CN/features/cron.json | 4 + dashboard/src/views/CronJobPage.vue | 171 ++++++++-- tests/unit/test_astr_main_agent.py | 29 +- tests/unit/test_cron_tools.py | 121 ++++++- 9 files changed, 583 insertions(+), 199 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 75f5d30e2..ebb95baa4 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -59,11 +59,7 @@ from astrbot.core.provider.entities import ProviderRequest from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt from astrbot.core.star.context import Context from astrbot.core.star.star_handler import star_map -from astrbot.core.tools.cron_tools import ( - CreateActiveCronTool, - DeleteCronJobTool, - ListCronJobsTool, -) +from astrbot.core.tools.cron_tools import FutureTaskTool from astrbot.core.tools.knowledge_base_tools import ( KnowledgeBaseQueryTool, retrieve_knowledge_base, @@ -1064,9 +1060,7 @@ def _proactive_cron_job_tools(req: ProviderRequest, plugin_context: Context) -> if req.func_tool is None: req.func_tool = ToolSet() tool_mgr = plugin_context.get_llm_tool_manager() - req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateActiveCronTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(DeleteCronJobTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListCronJobsTool)) + req.func_tool.add_tool(tool_mgr.get_builtin_tool(FutureTaskTool)) async def _apply_web_search_tools( diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py index 599957e0a..ccc93172d 100644 --- a/astrbot/core/tools/cron_tools.py +++ b/astrbot/core/tools/cron_tools.py @@ -18,40 +18,58 @@ def _extract_job_session(job: Any) -> str | None: return str(session) if session is not None else None +def _parse_run_at(run_at: Any) -> datetime | None: + if run_at in (None, ""): + return None + return datetime.fromisoformat(str(run_at)) + + @builtin_tool @dataclass -class CreateActiveCronTool(FunctionTool[AstrAgentContext]): - name: str = "create_future_task" +class FutureTaskTool(FunctionTool[AstrAgentContext]): + name: str = "future_task" description: str = ( - "Create a future task for your future. Supports recurring cron expressions or one-time run_at datetime. " - "Use this when you or the user want scheduled follow-up or proactive actions." + "Manage your future tasks. " + "Use action='create' to schedule a recurring cron task or one-time run_at task. " + "Use action='edit' to update an existing task. " + "Use action='list' to inspect existing tasks. " + "Use action='delete' to remove a task by job_id." ) parameters: dict = Field( default_factory=lambda: { "type": "object", "properties": { + "action": { + "type": "string", + "enum": ["create", "edit", "delete", "list"], + "description": "Action to perform. 'list' takes no parameters. 'delete' requires only 'job_id'. 'edit' requires 'job_id' plus the fields to change.", + }, + "name": { + "type": "string", + "description": "Optional task label.", + }, "cron_expression": { "type": "string", - "description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.", - }, - "run_at": { - "type": "string", - "description": "ISO datetime for one-time execution, e.g., 2026-02-02T08:00:00+08:00. Use with run_once=true.", + "description": "Cron expression for a recurring schedule, e.g. '0 8 * * *' or '0 23 * * mon-fri'. Prefer named weekdays like 'mon-fri' or 'sat,sun' over numeric ranges like '1-5'.", }, "note": { "type": "string", "description": "Detailed instructions for your future agent to execute when it wakes.", }, - "name": { - "type": "string", - "description": "Optional label to recognize this future task.", - }, "run_once": { "type": "boolean", - "description": "If true, the task will run only once and then be deleted. Use run_at to specify the time.", + "description": "Run only once and delete after execution. Use with run_at.", + }, + "run_at": { + "type": "string", + "description": "ISO datetime for one-time execution, e.g. 2026-02-02T08:00:00+08:00.", + }, + "job_id": { + "type": "string", + "description": "Task ID. Required for 'delete' and 'edit'.", }, }, - "required": ["note"], + "required": ["action"], } ) @@ -62,130 +80,152 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]): if cron_mgr is None: return "error: cron manager is not available." - cron_expression = kwargs.get("cron_expression") - run_at = kwargs.get("run_at") - run_once = bool(kwargs.get("run_once", False)) - note = str(kwargs.get("note", "")).strip() - name = str(kwargs.get("name") or "").strip() or "active_agent_task" + action = str(kwargs.get("action") or "").strip().lower() + if action == "create": + cron_expression = kwargs.get("cron_expression") + run_at = kwargs.get("run_at") + run_once = bool(kwargs.get("run_once", False)) + note = str(kwargs.get("note", "")).strip() + name = str(kwargs.get("name") or "").strip() or "active_agent_task" - if not note: - return "error: note is required." - if run_once and not run_at: - return "error: run_at is required when run_once=true." - if (not run_once) and not cron_expression: - return "error: cron_expression is required when run_once=false." - if run_once and cron_expression: - cron_expression = None - run_at_dt = None - if run_at: + if not note: + return "error: note is required when action=create." + if run_once and not run_at: + return "error: run_at is required when run_once=true." + if (not run_once) and not cron_expression: + return "error: cron_expression is required when run_once=false." + if run_once and cron_expression: + cron_expression = None try: - run_at_dt = datetime.fromisoformat(str(run_at)) + run_at_dt = _parse_run_at(run_at) except Exception: return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00" - payload = { - "session": context.context.event.unified_msg_origin, - "sender_id": context.context.event.get_sender_id(), - "note": note, - "origin": "tool", - } + payload = { + "session": context.context.event.unified_msg_origin, + "sender_id": context.context.event.get_sender_id(), + "note": note, + "origin": "tool", + } - job = await cron_mgr.add_active_job( - name=name, - cron_expression=str(cron_expression) if cron_expression else None, - payload=payload, - description=note, - run_once=run_once, - run_at=run_at_dt, - ) - next_run = job.next_run_time or run_at_dt - suffix = ( - f"one-time at {next_run}" - if run_once - else f"expression '{cron_expression}' (next {next_run})" - ) - return f"Scheduled future task {job.job_id} ({job.name}) {suffix}." - - -@builtin_tool -@dataclass -class DeleteCronJobTool(FunctionTool[AstrAgentContext]): - name: str = "delete_future_task" - description: str = "Delete a future task (cron job) by its job_id." - parameters: dict = Field( - default_factory=lambda: { - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "The job_id returned when the job was created.", - } - }, - "required": ["job_id"], - } - ) - - async def call( - self, context: ContextWrapper[AstrAgentContext], **kwargs - ) -> ToolExecResult: - cron_mgr = context.context.context.cron_manager - if cron_mgr is None: - return "error: cron manager is not available." - current_umo = context.context.event.unified_msg_origin - job_id = kwargs.get("job_id") - if not job_id: - return "error: job_id is required." - job = await cron_mgr.db.get_cron_job(str(job_id)) - if not job: - return f"error: cron job {job_id} not found." - if _extract_job_session(job) != current_umo: - return "error: you can only delete future tasks in the current umo." - await cron_mgr.delete_job(str(job_id)) - return f"Deleted cron job {job_id}." - - -@builtin_tool -@dataclass -class ListCronJobsTool(FunctionTool[AstrAgentContext]): - name: str = "list_future_tasks" - description: str = "List existing future tasks (cron jobs) for inspection." - parameters: dict = Field( - default_factory=lambda: { - "type": "object", - "properties": { - "job_type": { - "type": "string", - "description": "Optional filter: basic or active_agent.", - } - }, - } - ) - - async def call( - self, context: ContextWrapper[AstrAgentContext], **kwargs - ) -> ToolExecResult: - cron_mgr = context.context.context.cron_manager - if cron_mgr is None: - return "error: cron manager is not available." - current_umo = context.context.event.unified_msg_origin - job_type = kwargs.get("job_type") - jobs = [ - job - for job in await cron_mgr.list_jobs(job_type) - if _extract_job_session(job) == current_umo - ] - if not jobs: - return "No cron jobs found." - lines = [] - for j in jobs: - lines.append( - f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}" + job = await cron_mgr.add_active_job( + name=name, + cron_expression=str(cron_expression) if cron_expression else None, + payload=payload, + description=note, + run_once=run_once, + run_at=run_at_dt, ) - return "\n".join(lines) + next_run = job.next_run_time or run_at_dt + suffix = ( + f"one-time at {next_run}" + if run_once + else f"expression '{cron_expression}' (next {next_run})" + ) + return f"Scheduled future task {job.job_id} ({job.name}) {suffix}." + + current_umo = context.context.event.unified_msg_origin + if action == "edit": + job_id = kwargs.get("job_id") + if not job_id: + return "error: job_id is required when action=edit." + if not any( + key in kwargs + for key in ("name", "note", "run_once", "cron_expression", "run_at") + ): + return "error: no editable fields were provided." + + job = await cron_mgr.db.get_cron_job(str(job_id)) + if not job: + return f"error: cron job {job_id} not found." + if _extract_job_session(job) != current_umo: + return "error: you can only edit future tasks in the current umo." + + payload = dict(job.payload) if isinstance(job.payload, dict) else {} + + updates: dict[str, Any] = {} + if "name" in kwargs: + name = str(kwargs.get("name") or "").strip() + if not name: + return "error: name cannot be empty when action=edit." + updates["name"] = name + + if "note" in kwargs: + note = str(kwargs.get("note") or "").strip() + if not note: + return "error: note cannot be empty when action=edit." + payload["note"] = note + updates["description"] = note + + current_run_at = payload.get("run_at") + run_once = ( + bool(kwargs["run_once"]) if "run_once" in kwargs else bool(job.run_once) + ) + cron_expression = ( + str(kwargs.get("cron_expression") or "").strip() + if "cron_expression" in kwargs + else job.cron_expression + ) + cron_expression = cron_expression or None + + try: + run_at_dt = ( + _parse_run_at(kwargs.get("run_at")) + if "run_at" in kwargs + else _parse_run_at(current_run_at) + ) + except Exception: + return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00" + + if run_once: + if run_at_dt is None: + return "error: run_at is required when run_once=true." + cron_expression = None + payload["run_at"] = run_at_dt.isoformat() + else: + if not cron_expression: + return "error: cron_expression is required when run_once=false." + payload.pop("run_at", None) + + updates["run_once"] = run_once + updates["cron_expression"] = cron_expression + updates["payload"] = payload + + job = await cron_mgr.update_job(str(job_id), **updates) + if not job: + return f"error: cron job {job_id} not found." + return f"Updated future task {job.job_id} ({job.name})." + + if action == "delete": + job_id = kwargs.get("job_id") + if not job_id: + return "error: job_id is required when action=delete." + job = await cron_mgr.db.get_cron_job(str(job_id)) + if not job: + return f"error: cron job {job_id} not found." + if _extract_job_session(job) != current_umo: + return "error: you can only delete future tasks in the current umo." + await cron_mgr.delete_job(str(job_id)) + return f"Deleted cron job {job_id}." + + if action == "list": + jobs = [ + job + for job in await cron_mgr.list_jobs() + if _extract_job_session(job) == current_umo + ] + if not jobs: + return "No cron jobs found." + lines = [] + for j in jobs: + lines.append( + f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}" + ) + return "\n".join(lines) + + return "error: action must be one of create, edit, delete, or list." __all__ = [ - "CreateActiveCronTool", - "DeleteCronJobTool", - "ListCronJobsTool", + "FutureTaskTool", ] diff --git a/astrbot/dashboard/routes/cron.py b/astrbot/dashboard/routes/cron.py index 8861fc5cc..af26c6525 100644 --- a/astrbot/dashboard/routes/cron.py +++ b/astrbot/dashboard/routes/cron.py @@ -135,22 +135,127 @@ class CronRoute(Route): if not isinstance(payload, dict): return jsonify(Response().error("Invalid payload").__dict__) - updates = { - "name": payload.get("name"), - "cron_expression": payload.get("cron_expression"), - "description": payload.get("description"), - "enabled": payload.get("enabled"), - "timezone": payload.get("timezone"), - "run_once": payload.get("run_once"), - "payload": payload.get("payload"), - } - # remove None values to avoid unwanted resets - updates = {k: v for k, v in updates.items() if v is not None} - if "run_at" in payload: - updates.setdefault("payload", {}) - if updates["payload"] is None: - updates["payload"] = {} - updates["payload"]["run_at"] = payload.get("run_at") + job = await cron_mgr.db.get_cron_job(job_id) + if not job: + return jsonify(Response().error("Job not found").__dict__) + + updates = {} + if "name" in payload: + name = str(payload.get("name") or "").strip() + if not name: + return jsonify(Response().error("name cannot be empty").__dict__) + updates["name"] = name + + if "enabled" in payload: + updates["enabled"] = bool(payload.get("enabled")) + + if "timezone" in payload: + timezone = payload.get("timezone") + updates["timezone"] = str(timezone).strip() or None + + next_run_once = ( + bool(payload.get("run_once")) + if "run_once" in payload + else bool(job.run_once) + ) + + if job.job_type == "active_agent": + merged_payload = ( + dict(job.payload) if isinstance(job.payload, dict) else {} + ) + if "payload" in payload and isinstance(payload.get("payload"), dict): + merged_payload.update(payload["payload"]) + + if "session" in payload: + session = str(payload.get("session") or "").strip() + if not session: + return jsonify( + Response().error("session cannot be empty").__dict__ + ) + merged_payload["session"] = session + + note_updated = False + if "note" in payload: + note = str(payload.get("note") or "").strip() + if not note: + return jsonify( + Response().error("note cannot be empty").__dict__ + ) + merged_payload["note"] = note + updates["description"] = note + note_updated = True + elif "description" in payload: + description = str(payload.get("description") or "").strip() + if not description: + return jsonify( + Response().error("description cannot be empty").__dict__ + ) + updates["description"] = description + merged_payload["note"] = description + note_updated = True + + if not note_updated and updates.get("description") is None: + existing_note = str( + merged_payload.get("note") or job.description or "" + ).strip() + if existing_note: + merged_payload["note"] = existing_note + + next_cron_expression = ( + payload.get("cron_expression") + if "cron_expression" in payload + else job.cron_expression + ) + if next_cron_expression is not None: + next_cron_expression = str(next_cron_expression).strip() or None + + run_at_raw = ( + payload.get("run_at") + if "run_at" in payload + else merged_payload.get("run_at") + ) + run_at_iso = None + if run_at_raw: + try: + run_at_iso = datetime.fromisoformat(str(run_at_raw)).isoformat() + except Exception: + return jsonify( + Response().error("run_at must be ISO datetime").__dict__ + ) + + if next_run_once: + if not run_at_iso: + return jsonify( + Response() + .error("run_at is required when run_once=true") + .__dict__ + ) + next_cron_expression = None + merged_payload["run_at"] = run_at_iso + else: + if not next_cron_expression: + return jsonify( + Response() + .error("cron_expression is required when run_once=false") + .__dict__ + ) + merged_payload.pop("run_at", None) + + updates["run_once"] = next_run_once + updates["cron_expression"] = next_cron_expression + updates["payload"] = merged_payload + else: + if "cron_expression" in payload: + cron_expression = str(payload.get("cron_expression") or "").strip() + if not cron_expression: + return jsonify( + Response().error("cron_expression cannot be empty").__dict__ + ) + updates["cron_expression"] = cron_expression + + if "description" in payload: + description = str(payload.get("description") or "").strip() + updates["description"] = description or None job = await cron_mgr.update_job(job_id, **updates) if not job: diff --git a/dashboard/src/i18n/locales/en-US/features/cron.json b/dashboard/src/i18n/locales/en-US/features/cron.json index 6635a6fbe..a3bafd4d6 100644 --- a/dashboard/src/i18n/locales/en-US/features/cron.json +++ b/dashboard/src/i18n/locales/en-US/features/cron.json @@ -14,9 +14,11 @@ }, "actions": { "create": "New Task", + "edit": "Edit", "refresh": "Refresh", "delete": "Delete", "cancel": "Cancel", + "save": "Save", "submit": "Create" }, "overview": { @@ -77,6 +79,7 @@ }, "form": { "title": "New Task", + "editTitle": "Edit Task", "chatHint": "You can ask AstrBot in chat to create future tasks instead of adding them here.", "runOnce": "One-off task", "name": "Task name", @@ -90,6 +93,7 @@ }, "messages": { "loadFailed": "Failed to load tasks", + "updateSuccess": "Updated successfully", "updateFailed": "Failed to update", "deleteSuccess": "Deleted", "deleteFailed": "Failed to delete", diff --git a/dashboard/src/i18n/locales/ru-RU/features/cron.json b/dashboard/src/i18n/locales/ru-RU/features/cron.json index a7d36864f..76b875ad1 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/cron.json +++ b/dashboard/src/i18n/locales/ru-RU/features/cron.json @@ -14,9 +14,11 @@ }, "actions": { "create": "Новая задача", + "edit": "Изменить", "refresh": "Обновить", "delete": "Удалить", "cancel": "Отмена", + "save": "Сохранить", "submit": "Создать" }, "overview": { @@ -77,6 +79,7 @@ }, "form": { "title": "Создать задачу", + "editTitle": "Редактировать задачу", "chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.", "runOnce": "Разовая задача", "name": "Имя задачи", @@ -90,6 +93,7 @@ }, "messages": { "loadFailed": "Ошибка загрузки задач", + "updateSuccess": "Задача обновлена", "updateFailed": "Ошибка обновления", "deleteSuccess": "Удалено", "deleteFailed": "Ошибка удаления", diff --git a/dashboard/src/i18n/locales/zh-CN/features/cron.json b/dashboard/src/i18n/locales/zh-CN/features/cron.json index 5e8b4b572..2345efe98 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/cron.json +++ b/dashboard/src/i18n/locales/zh-CN/features/cron.json @@ -14,9 +14,11 @@ }, "actions": { "create": "新建", + "edit": "编辑", "refresh": "刷新", "delete": "删除", "cancel": "取消", + "save": "保存", "submit": "创建" }, "overview": { @@ -77,6 +79,7 @@ }, "form": { "title": "新建任务", + "editTitle": "编辑任务", "chatHint": "你可以直接通过聊天的方式来让 AstrBot 创建未来任务,而不必在此添加。", "runOnce": "一次性任务", "name": "任务名称", @@ -90,6 +93,7 @@ }, "messages": { "loadFailed": "获取任务失败", + "updateSuccess": "更新成功", "updateFailed": "更新失败", "deleteSuccess": "已删除", "deleteFailed": "删除失败", diff --git a/dashboard/src/views/CronJobPage.vue b/dashboard/src/views/CronJobPage.vue index 0684be722..dbc8f410e 100644 --- a/dashboard/src/views/CronJobPage.vue +++ b/dashboard/src/views/CronJobPage.vue @@ -136,18 +136,31 @@