Skip to content

Commit c791c81

Browse files
authored
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
1 parent e34d950 commit c791c81

File tree

9 files changed

+581
-197
lines changed

9 files changed

+581
-197
lines changed

astrbot/core/astr_main_agent.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,7 @@
5959
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
6060
from astrbot.core.star.context import Context
6161
from astrbot.core.star.star_handler import star_map
62-
from astrbot.core.tools.cron_tools import (
63-
CreateActiveCronTool,
64-
DeleteCronJobTool,
65-
ListCronJobsTool,
66-
)
62+
from astrbot.core.tools.cron_tools import FutureTaskTool
6763
from astrbot.core.tools.knowledge_base_tools import (
6864
KnowledgeBaseQueryTool,
6965
retrieve_knowledge_base,
@@ -1064,9 +1060,7 @@ def _proactive_cron_job_tools(req: ProviderRequest, plugin_context: Context) ->
10641060
if req.func_tool is None:
10651061
req.func_tool = ToolSet()
10661062
tool_mgr = plugin_context.get_llm_tool_manager()
1067-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateActiveCronTool))
1068-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(DeleteCronJobTool))
1069-
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListCronJobsTool))
1063+
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FutureTaskTool))
10701064

10711065

10721066
async def _apply_web_search_tools(

astrbot/core/tools/cron_tools.py

Lines changed: 169 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -18,40 +18,58 @@ def _extract_job_session(job: Any) -> str | None:
1818
return str(session) if session is not None else None
1919

2020

21+
def _parse_run_at(run_at: Any) -> datetime | None:
22+
if run_at in (None, ""):
23+
return None
24+
return datetime.fromisoformat(str(run_at))
25+
26+
2127
@builtin_tool
2228
@dataclass
23-
class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
24-
name: str = "create_future_task"
29+
class FutureTaskTool(FunctionTool[AstrAgentContext]):
30+
name: str = "future_task"
2531
description: str = (
26-
"Create a future task for your future. Supports recurring cron expressions or one-time run_at datetime. "
27-
"Use this when you or the user want scheduled follow-up or proactive actions."
32+
"Manage your future tasks. "
33+
"Use action='create' to schedule a recurring cron task or one-time run_at task. "
34+
"Use action='edit' to update an existing task. "
35+
"Use action='list' to inspect existing tasks. "
36+
"Use action='delete' to remove a task by job_id."
2837
)
2938
parameters: dict = Field(
3039
default_factory=lambda: {
3140
"type": "object",
3241
"properties": {
33-
"cron_expression": {
42+
"action": {
3443
"type": "string",
35-
"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.",
44+
"enum": ["create", "edit", "delete", "list"],
45+
"description": "Action to perform. 'list' takes no parameters. 'delete' requires only 'job_id'. 'edit' requires 'job_id' plus the fields to change.",
3646
},
37-
"run_at": {
47+
"name": {
3848
"type": "string",
39-
"description": "ISO datetime for one-time execution, e.g., 2026-02-02T08:00:00+08:00. Use with run_once=true.",
49+
"description": "Optional task label.",
4050
},
41-
"note": {
51+
"cron_expression": {
4252
"type": "string",
43-
"description": "Detailed instructions for your future agent to execute when it wakes.",
53+
"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'.",
4454
},
45-
"name": {
55+
"note": {
4656
"type": "string",
47-
"description": "Optional label to recognize this future task.",
57+
"description": "Detailed instructions for your future agent to execute when it wakes.",
4858
},
4959
"run_once": {
5060
"type": "boolean",
51-
"description": "If true, the task will run only once and then be deleted. Use run_at to specify the time.",
61+
"description": "Run only once and delete after execution. Use with run_at.",
62+
},
63+
"run_at": {
64+
"type": "string",
65+
"description": "ISO datetime for one-time execution, e.g. 2026-02-02T08:00:00+08:00.",
66+
},
67+
"job_id": {
68+
"type": "string",
69+
"description": "Task ID. Required for 'delete' and 'edit'.",
5270
},
5371
},
54-
"required": ["note"],
72+
"required": ["action"],
5573
}
5674
)
5775

@@ -62,130 +80,152 @@ async def call(
6280
if cron_mgr is None:
6381
return "error: cron manager is not available."
6482

65-
cron_expression = kwargs.get("cron_expression")
66-
run_at = kwargs.get("run_at")
67-
run_once = bool(kwargs.get("run_once", False))
68-
note = str(kwargs.get("note", "")).strip()
69-
name = str(kwargs.get("name") or "").strip() or "active_agent_task"
70-
71-
if not note:
72-
return "error: note is required."
73-
if run_once and not run_at:
74-
return "error: run_at is required when run_once=true."
75-
if (not run_once) and not cron_expression:
76-
return "error: cron_expression is required when run_once=false."
77-
if run_once and cron_expression:
78-
cron_expression = None
79-
run_at_dt = None
80-
if run_at:
83+
action = str(kwargs.get("action") or "").strip().lower()
84+
if action == "create":
85+
cron_expression = kwargs.get("cron_expression")
86+
run_at = kwargs.get("run_at")
87+
run_once = bool(kwargs.get("run_once", False))
88+
note = str(kwargs.get("note", "")).strip()
89+
name = str(kwargs.get("name") or "").strip() or "active_agent_task"
90+
91+
if not note:
92+
return "error: note is required when action=create."
93+
if run_once and not run_at:
94+
return "error: run_at is required when run_once=true."
95+
if (not run_once) and not cron_expression:
96+
return "error: cron_expression is required when run_once=false."
97+
if run_once and cron_expression:
98+
cron_expression = None
8199
try:
82-
run_at_dt = datetime.fromisoformat(str(run_at))
100+
run_at_dt = _parse_run_at(run_at)
83101
except Exception:
84102
return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00"
85103

86-
payload = {
87-
"session": context.context.event.unified_msg_origin,
88-
"sender_id": context.context.event.get_sender_id(),
89-
"note": note,
90-
"origin": "tool",
91-
}
92-
93-
job = await cron_mgr.add_active_job(
94-
name=name,
95-
cron_expression=str(cron_expression) if cron_expression else None,
96-
payload=payload,
97-
description=note,
98-
run_once=run_once,
99-
run_at=run_at_dt,
100-
)
101-
next_run = job.next_run_time or run_at_dt
102-
suffix = (
103-
f"one-time at {next_run}"
104-
if run_once
105-
else f"expression '{cron_expression}' (next {next_run})"
106-
)
107-
return f"Scheduled future task {job.job_id} ({job.name}) {suffix}."
108-
109-
110-
@builtin_tool
111-
@dataclass
112-
class DeleteCronJobTool(FunctionTool[AstrAgentContext]):
113-
name: str = "delete_future_task"
114-
description: str = "Delete a future task (cron job) by its job_id."
115-
parameters: dict = Field(
116-
default_factory=lambda: {
117-
"type": "object",
118-
"properties": {
119-
"job_id": {
120-
"type": "string",
121-
"description": "The job_id returned when the job was created.",
122-
}
123-
},
124-
"required": ["job_id"],
125-
}
126-
)
104+
payload = {
105+
"session": context.context.event.unified_msg_origin,
106+
"sender_id": context.context.event.get_sender_id(),
107+
"note": note,
108+
"origin": "tool",
109+
}
110+
111+
job = await cron_mgr.add_active_job(
112+
name=name,
113+
cron_expression=str(cron_expression) if cron_expression else None,
114+
payload=payload,
115+
description=note,
116+
run_once=run_once,
117+
run_at=run_at_dt,
118+
)
119+
next_run = job.next_run_time or run_at_dt
120+
suffix = (
121+
f"one-time at {next_run}"
122+
if run_once
123+
else f"expression '{cron_expression}' (next {next_run})"
124+
)
125+
return f"Scheduled future task {job.job_id} ({job.name}) {suffix}."
127126

128-
async def call(
129-
self, context: ContextWrapper[AstrAgentContext], **kwargs
130-
) -> ToolExecResult:
131-
cron_mgr = context.context.context.cron_manager
132-
if cron_mgr is None:
133-
return "error: cron manager is not available."
134127
current_umo = context.context.event.unified_msg_origin
135-
job_id = kwargs.get("job_id")
136-
if not job_id:
137-
return "error: job_id is required."
138-
job = await cron_mgr.db.get_cron_job(str(job_id))
139-
if not job:
140-
return f"error: cron job {job_id} not found."
141-
if _extract_job_session(job) != current_umo:
142-
return "error: you can only delete future tasks in the current umo."
143-
await cron_mgr.delete_job(str(job_id))
144-
return f"Deleted cron job {job_id}."
145-
128+
if action == "edit":
129+
job_id = kwargs.get("job_id")
130+
if not job_id:
131+
return "error: job_id is required when action=edit."
132+
if not any(
133+
key in kwargs
134+
for key in ("name", "note", "run_once", "cron_expression", "run_at")
135+
):
136+
return "error: no editable fields were provided."
137+
138+
job = await cron_mgr.db.get_cron_job(str(job_id))
139+
if not job:
140+
return f"error: cron job {job_id} not found."
141+
if _extract_job_session(job) != current_umo:
142+
return "error: you can only edit future tasks in the current umo."
143+
144+
payload = dict(job.payload) if isinstance(job.payload, dict) else {}
145+
146+
updates: dict[str, Any] = {}
147+
if "name" in kwargs:
148+
name = str(kwargs.get("name") or "").strip()
149+
if not name:
150+
return "error: name cannot be empty when action=edit."
151+
updates["name"] = name
152+
153+
if "note" in kwargs:
154+
note = str(kwargs.get("note") or "").strip()
155+
if not note:
156+
return "error: note cannot be empty when action=edit."
157+
payload["note"] = note
158+
updates["description"] = note
159+
160+
current_run_at = payload.get("run_at")
161+
run_once = (
162+
bool(kwargs["run_once"]) if "run_once" in kwargs else bool(job.run_once)
163+
)
164+
cron_expression = (
165+
str(kwargs.get("cron_expression") or "").strip()
166+
if "cron_expression" in kwargs
167+
else job.cron_expression
168+
)
169+
cron_expression = cron_expression or None
146170

147-
@builtin_tool
148-
@dataclass
149-
class ListCronJobsTool(FunctionTool[AstrAgentContext]):
150-
name: str = "list_future_tasks"
151-
description: str = "List existing future tasks (cron jobs) for inspection."
152-
parameters: dict = Field(
153-
default_factory=lambda: {
154-
"type": "object",
155-
"properties": {
156-
"job_type": {
157-
"type": "string",
158-
"description": "Optional filter: basic or active_agent.",
159-
}
160-
},
161-
}
162-
)
171+
try:
172+
run_at_dt = (
173+
_parse_run_at(kwargs.get("run_at"))
174+
if "run_at" in kwargs
175+
else _parse_run_at(current_run_at)
176+
)
177+
except Exception:
178+
return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00"
163179

164-
async def call(
165-
self, context: ContextWrapper[AstrAgentContext], **kwargs
166-
) -> ToolExecResult:
167-
cron_mgr = context.context.context.cron_manager
168-
if cron_mgr is None:
169-
return "error: cron manager is not available."
170-
current_umo = context.context.event.unified_msg_origin
171-
job_type = kwargs.get("job_type")
172-
jobs = [
173-
job
174-
for job in await cron_mgr.list_jobs(job_type)
175-
if _extract_job_session(job) == current_umo
176-
]
177-
if not jobs:
178-
return "No cron jobs found."
179-
lines = []
180-
for j in jobs:
181-
lines.append(
182-
f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}"
183-
)
184-
return "\n".join(lines)
180+
if run_once:
181+
if run_at_dt is None:
182+
return "error: run_at is required when run_once=true."
183+
cron_expression = None
184+
payload["run_at"] = run_at_dt.isoformat()
185+
else:
186+
if not cron_expression:
187+
return "error: cron_expression is required when run_once=false."
188+
payload.pop("run_at", None)
189+
190+
updates["run_once"] = run_once
191+
updates["cron_expression"] = cron_expression
192+
updates["payload"] = payload
193+
194+
job = await cron_mgr.update_job(str(job_id), **updates)
195+
if not job:
196+
return f"error: cron job {job_id} not found."
197+
return f"Updated future task {job.job_id} ({job.name})."
198+
199+
if action == "delete":
200+
job_id = kwargs.get("job_id")
201+
if not job_id:
202+
return "error: job_id is required when action=delete."
203+
job = await cron_mgr.db.get_cron_job(str(job_id))
204+
if not job:
205+
return f"error: cron job {job_id} not found."
206+
if _extract_job_session(job) != current_umo:
207+
return "error: you can only delete future tasks in the current umo."
208+
await cron_mgr.delete_job(str(job_id))
209+
return f"Deleted cron job {job_id}."
210+
211+
if action == "list":
212+
jobs = [
213+
job
214+
for job in await cron_mgr.list_jobs()
215+
if _extract_job_session(job) == current_umo
216+
]
217+
if not jobs:
218+
return "No cron jobs found."
219+
lines = []
220+
for j in jobs:
221+
lines.append(
222+
f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}"
223+
)
224+
return "\n".join(lines)
225+
226+
return "error: action must be one of create, edit, delete, or list."
185227

186228

187229
__all__ = [
188-
"CreateActiveCronTool",
189-
"DeleteCronJobTool",
190-
"ListCronJobsTool",
230+
"FutureTaskTool",
191231
]

0 commit comments

Comments
 (0)