main
py 431 lines 18 KB
Raw
1 import asyncio
2 from datetime import datetime
3 import json
4 import random
5 import re
6 from typing import Any
7 import pytz
8 from helpers.tool import Tool, Response
9 from helpers.task_scheduler import (
10 TaskScheduler, ScheduledTask, AdHocTask, PlannedTask,
11 serialize_task, TaskState, TaskSchedule, TaskPlan, parse_datetime,
12 parse_task_plan, serialize_datetime
13 )
14 from agent import AgentContext
15 from helpers import persist_chat
16 from helpers.localization import Localization
17 from helpers.projects import get_context_project_name, load_basic_project_data
18
19 DEFAULT_WAIT_TIMEOUT = 300
20 LOCAL_TIMEZONE_ALIASES = {"local", "user", "default", "current", "current_timezone"}
21
22
23 def _current_action(tool: Tool, kwargs: dict) -> str:
24 return (
25 str(
26 kwargs.get("action")
27 or tool.args.get("action")
28 or ""
29 )
30 .strip()
31 .lower()
32 .replace("-", "_")
33 )
34
35
36 def _normalize_timezone(value: Any) -> str | None:
37 if value is None:
38 return None
39 timezone_name = str(value).strip()
40 if not timezone_name:
41 return None
42 if timezone_name.lower() in LOCAL_TIMEZONE_ALIASES:
43 return Localization.get().get_timezone()
44 try:
45 pytz.timezone(timezone_name)
46 except pytz.exceptions.UnknownTimeZoneError as exc:
47 raise ValueError(
48 f"Invalid timezone: {timezone_name}. Use an IANA timezone name such as Europe/Rome, "
49 "or omit timezone to use the current user timezone."
50 ) from exc
51 return timezone_name
52
53
54 def _schedule_timezone(kwargs: dict) -> str | None:
55 schedule = kwargs.get("schedule")
56 if isinstance(schedule, dict) and schedule.get("timezone"):
57 return _normalize_timezone(schedule["timezone"])
58 if kwargs.get("timezone"):
59 return _normalize_timezone(kwargs["timezone"])
60 return None
61
62
63 def _task_schedule_from_input(schedule: Any, timezone: str | None = None) -> TaskSchedule:
64 if isinstance(schedule, str):
65 parts = schedule.split()
66 schedule_data: dict[str, Any] = {
67 "minute": parts[0] if len(parts) > 0 else "*",
68 "hour": parts[1] if len(parts) > 1 else "*",
69 "day": parts[2] if len(parts) > 2 else "*",
70 "month": parts[3] if len(parts) > 3 else "*",
71 "weekday": parts[4] if len(parts) > 4 else "*",
72 }
73 elif isinstance(schedule, dict):
74 schedule_data = dict(schedule)
75 else:
76 schedule_data = {}
77
78 task_schedule_kwargs = {
79 "minute": str(schedule_data.get("minute", "*")),
80 "hour": str(schedule_data.get("hour", "*")),
81 "day": str(schedule_data.get("day", "*")),
82 "month": str(schedule_data.get("month", "*")),
83 "weekday": str(schedule_data.get("weekday", "*")),
84 }
85 normalized_timezone = _normalize_timezone(timezone if timezone is not None else schedule_data.get("timezone"))
86 if normalized_timezone:
87 task_schedule_kwargs["timezone"] = normalized_timezone
88
89 return TaskSchedule(**task_schedule_kwargs)
90
91
92 def _validate_task_schedule(task_schedule: TaskSchedule) -> str:
93 # Validate cron expression, agent might hallucinate
94 cron_regex = r"^((((\d+,)+\d+|(\d+(\/|-|#)\d+)|\d+L?|\*(\/\d+)?|L(-\d+)?|\?|[A-Z]{3}(-[A-Z]{3})?) ?){5,7})$"
95 crontab = task_schedule.to_crontab()
96 return "" if re.match(cron_regex, crontab) else f"Invalid cron expression: {crontab}"
97
98
99 def _task_plan_from_input(plan: Any) -> tuple[TaskPlan | None, str]:
100 if isinstance(plan, dict):
101 try:
102 return parse_task_plan(plan), ""
103 except Exception as exc:
104 return None, f"Invalid plan: {exc}"
105
106 if not isinstance(plan, list):
107 return None, "Plan must be an array of ISO datetimes."
108
109 todo: list[datetime] = []
110 for item in plan:
111 dt = parse_datetime(str(item))
112 if dt is None:
113 return None, f"Invalid datetime: {item}"
114 todo.append(dt)
115
116 return TaskPlan.create(todo=todo, in_progress=None, done=[]), ""
117
118
119 class SchedulerTool(Tool):
120
121 async def execute(self, **kwargs):
122 action = _current_action(self, kwargs)
123 if action == "list_tasks":
124 return await self.list_tasks(**kwargs)
125 elif action == "find_task_by_name":
126 return await self.find_task_by_name(**kwargs)
127 elif action == "show_task":
128 return await self.show_task(**kwargs)
129 elif action == "run_task":
130 return await self.run_task(**kwargs)
131 elif action == "delete_task":
132 return await self.delete_task(**kwargs)
133 elif action == "update_task":
134 return await self.update_task(**kwargs)
135 elif action == "create_scheduled_task":
136 return await self.create_scheduled_task(**kwargs)
137 elif action == "create_adhoc_task":
138 return await self.create_adhoc_task(**kwargs)
139 elif action == "create_planned_task":
140 return await self.create_planned_task(**kwargs)
141 elif action == "wait_for_task":
142 return await self.wait_for_task(**kwargs)
143 else:
144 return Response(
145 message=(
146 f"Unknown scheduler action '{action or self.method or ''}'. "
147 "Supported actions: list_tasks, find_task_by_name, show_task, "
148 "run_task, delete_task, update_task, create_scheduled_task, "
149 "create_adhoc_task, create_planned_task, wait_for_task."
150 ),
151 break_loop=False,
152 )
153
154 def _resolve_project_metadata(self) -> tuple[str | None, str | None]:
155 context = self.agent.context
156 if not context:
157 return (None, None)
158 project_slug = get_context_project_name(context)
159 if not project_slug:
160 return (None, None)
161 try:
162 metadata = load_basic_project_data(project_slug)
163 color = metadata.get("color") or None
164 except Exception:
165 color = None
166 return project_slug, color
167
168 async def list_tasks(self, **kwargs) -> Response:
169 state_filter: list[str] | None = kwargs.get("state", None)
170 type_filter: list[str] | None = kwargs.get("type", None)
171 next_run_within_filter: int | None = kwargs.get("next_run_within", None)
172 next_run_after_filter: int | None = kwargs.get("next_run_after", None)
173
174 tasks: list[ScheduledTask | AdHocTask | PlannedTask] = TaskScheduler.get().get_tasks()
175 filtered_tasks = []
176 for task in tasks:
177 if state_filter and task.state not in state_filter:
178 continue
179 if type_filter and task.type not in type_filter:
180 continue
181 if next_run_within_filter and task.get_next_run_minutes() is not None and task.get_next_run_minutes() > next_run_within_filter: # type: ignore
182 continue
183 if next_run_after_filter and task.get_next_run_minutes() is not None and task.get_next_run_minutes() < next_run_after_filter: # type: ignore
184 continue
185 filtered_tasks.append(serialize_task(task))
186
187 return Response(message=json.dumps(filtered_tasks, indent=4), break_loop=False)
188
189 async def find_task_by_name(self, **kwargs) -> Response:
190 name: str = kwargs.get("name", "")
191 if not name:
192 return Response(message="Task name is required", break_loop=False)
193 tasks: list[ScheduledTask | AdHocTask | PlannedTask] = TaskScheduler.get().find_task_by_name(name)
194 if not tasks:
195 return Response(message=f"Task not found: {name}", break_loop=False)
196 return Response(message=json.dumps([serialize_task(task) for task in tasks], indent=4), break_loop=False)
197
198 async def show_task(self, **kwargs) -> Response:
199 task_uuid: str = kwargs.get("uuid", "")
200 if not task_uuid:
201 return Response(message="Task UUID is required", break_loop=False)
202 task: ScheduledTask | AdHocTask | PlannedTask | None = TaskScheduler.get().get_task_by_uuid(task_uuid)
203 if not task:
204 return Response(message=f"Task not found: {task_uuid}", break_loop=False)
205 return Response(message=json.dumps(serialize_task(task), indent=4), break_loop=False)
206
207 async def run_task(self, **kwargs) -> Response:
208 task_uuid: str = kwargs.get("uuid", "")
209 if not task_uuid:
210 return Response(message="Task UUID is required", break_loop=False)
211 task_context: str | None = kwargs.get("context", None)
212 task: ScheduledTask | AdHocTask | PlannedTask | None = TaskScheduler.get().get_task_by_uuid(task_uuid)
213 if not task:
214 return Response(message=f"Task not found: {task_uuid}", break_loop=False)
215 await TaskScheduler.get().run_task_by_uuid(task_uuid, task_context)
216 if task.context_id == self.agent.context.id:
217 break_loop = True # break loop if task is running in the same context, otherwise it would start two conversations in one window
218 else:
219 break_loop = False
220 return Response(message=f"Task started: {task_uuid}", break_loop=break_loop)
221
222 async def delete_task(self, **kwargs) -> Response:
223 task_uuid: str = kwargs.get("uuid", "")
224 if not task_uuid:
225 return Response(message="Task UUID is required", break_loop=False)
226
227 task: ScheduledTask | AdHocTask | PlannedTask | None = TaskScheduler.get().get_task_by_uuid(task_uuid)
228 if not task:
229 return Response(message=f"Task not found: {task_uuid}", break_loop=False)
230
231 context = None
232 if task.context_id:
233 context = AgentContext.get(task.context_id)
234
235 if task.state == TaskState.RUNNING:
236 if context:
237 context.reset()
238 await TaskScheduler.get().update_task(task_uuid, state=TaskState.IDLE)
239 await TaskScheduler.get().save()
240
241 if context and context.id == task.uuid:
242 AgentContext.remove(context.id)
243 persist_chat.remove_chat(context.id)
244
245 await TaskScheduler.get().remove_task_by_uuid(task_uuid)
246 if TaskScheduler.get().get_task_by_uuid(task_uuid) is None:
247 return Response(message=f"Task deleted: {task_uuid}", break_loop=False)
248 else:
249 return Response(message=f"Task failed to delete: {task_uuid}", break_loop=False)
250
251 async def update_task(self, **kwargs) -> Response:
252 task_uuid: str = kwargs.get("uuid", "")
253 if not task_uuid:
254 return Response(message="Task UUID is required", break_loop=False)
255
256 scheduler = TaskScheduler.get()
257 await scheduler.reload()
258 task: ScheduledTask | AdHocTask | PlannedTask | None = scheduler.get_task_by_uuid(task_uuid)
259 if not task:
260 return Response(message=f"Task not found: {task_uuid}", break_loop=False)
261
262 update_params: dict[str, Any] = {}
263 for field in ("name", "system_prompt", "prompt", "attachments"):
264 if field in kwargs:
265 update_params[field] = kwargs[field]
266
267 if "state" in kwargs:
268 update_params["state"] = TaskState(kwargs.get("state", TaskState.IDLE))
269
270 if "dedicated_context" in kwargs:
271 dedicated_context = bool(kwargs.get("dedicated_context"))
272 update_params["context_id"] = task.uuid if dedicated_context else self.agent.context.id
273
274 try:
275 timezone = _schedule_timezone(kwargs)
276 if isinstance(task, ScheduledTask) and ("schedule" in kwargs or timezone):
277 task_schedule = _task_schedule_from_input(
278 kwargs.get("schedule") or serialize_task(task).get("schedule") or {},
279 timezone=timezone,
280 )
281 if err := _validate_task_schedule(task_schedule):
282 return Response(message=err, break_loop=False)
283 update_params["schedule"] = task_schedule
284 except ValueError as exc:
285 return Response(message=str(exc), break_loop=False)
286
287 if isinstance(task, ScheduledTask) and "schedule" in update_params:
288 task_schedule = update_params["schedule"]
289 if err := _validate_task_schedule(task_schedule):
290 return Response(message=err, break_loop=False)
291 elif isinstance(task, PlannedTask) and "plan" in kwargs:
292 task_plan, err = _task_plan_from_input(kwargs.get("plan") or [])
293 if err:
294 return Response(message=err, break_loop=False)
295 update_params["plan"] = task_plan
296
297 updated_task = await scheduler.update_task(task_uuid, **update_params)
298 await scheduler.save()
299 if not updated_task:
300 return Response(message=f"Task failed to update: {task_uuid}", break_loop=False)
301
302 return Response(message=json.dumps(serialize_task(updated_task), indent=4), break_loop=False)
303
304 async def create_scheduled_task(self, **kwargs) -> Response:
305 # "name": "XXX",
306 # "system_prompt": "You are a software developer",
307 # "prompt": "Send the user an email with a greeting using python and smtp. The user's address is: xxx@yyy.zzz",
308 # "attachments": [],
309 # "schedule": {
310 # "minute": "*/20",
311 # "hour": "*",
312 # "day": "*",
313 # "month": "*",
314 # "weekday": "*",
315 # }
316 name: str = kwargs.get("name", "")
317 system_prompt: str = kwargs.get("system_prompt", "")
318 prompt: str = kwargs.get("prompt", "")
319 attachments: list[str] = kwargs.get("attachments", [])
320 schedule: dict[str, str] = kwargs.get("schedule", {})
321 dedicated_context: bool = kwargs.get("dedicated_context", True)
322
323 try:
324 task_schedule = _task_schedule_from_input(schedule, timezone=_schedule_timezone(kwargs))
325 except ValueError as exc:
326 return Response(message=str(exc), break_loop=False)
327
328 if err := _validate_task_schedule(task_schedule):
329 return Response(message=err, break_loop=False)
330
331 project_slug, project_color = self._resolve_project_metadata()
332
333 task = ScheduledTask.create(
334 name=name,
335 system_prompt=system_prompt,
336 prompt=prompt,
337 attachments=attachments,
338 schedule=task_schedule,
339 timezone=getattr(task_schedule, "timezone", None),
340 context_id=None if dedicated_context else self.agent.context.id,
341 project_name=project_slug,
342 project_color=project_color,
343 )
344 await TaskScheduler.get().add_task(task)
345 return Response(message=f"Scheduled task '{name}' created: {task.uuid}", break_loop=False)
346
347 async def create_adhoc_task(self, **kwargs) -> Response:
348 name: str = kwargs.get("name", "")
349 system_prompt: str = kwargs.get("system_prompt", "")
350 prompt: str = kwargs.get("prompt", "")
351 attachments: list[str] = kwargs.get("attachments", [])
352 token: str = str(random.randint(1000000000000000000, 9999999999999999999))
353 dedicated_context: bool = kwargs.get("dedicated_context", True)
354
355 project_slug, project_color = self._resolve_project_metadata()
356
357 task = AdHocTask.create(
358 name=name,
359 system_prompt=system_prompt,
360 prompt=prompt,
361 attachments=attachments,
362 token=token,
363 context_id=None if dedicated_context else self.agent.context.id,
364 project_name=project_slug,
365 project_color=project_color,
366 )
367 await TaskScheduler.get().add_task(task)
368 return Response(message=f"Adhoc task '{name}' created: {task.uuid}", break_loop=False)
369
370 async def create_planned_task(self, **kwargs) -> Response:
371 name: str = kwargs.get("name", "")
372 system_prompt: str = kwargs.get("system_prompt", "")
373 prompt: str = kwargs.get("prompt", "")
374 attachments: list[str] = kwargs.get("attachments", [])
375 plan: list[str] = kwargs.get("plan", [])
376 dedicated_context: bool = kwargs.get("dedicated_context", True)
377
378 # Convert plan to list of datetimes in UTC
379 task_plan, err = _task_plan_from_input(plan)
380 if err:
381 return Response(message=err, break_loop=False)
382
383 project_slug, project_color = self._resolve_project_metadata()
384
385 # Create planned task with task plan
386 task = PlannedTask.create(
387 name=name,
388 system_prompt=system_prompt,
389 prompt=prompt,
390 attachments=attachments,
391 plan=task_plan,
392 context_id=None if dedicated_context else self.agent.context.id,
393 project_name=project_slug,
394 project_color=project_color
395 )
396 await TaskScheduler.get().add_task(task)
397 return Response(message=f"Planned task '{name}' created: {task.uuid}", break_loop=False)
398
399 async def wait_for_task(self, **kwargs) -> Response:
400 task_uuid: str = kwargs.get("uuid", "")
401 if not task_uuid:
402 return Response(message="Task UUID is required", break_loop=False)
403
404 scheduler = TaskScheduler.get()
405 task: ScheduledTask | AdHocTask | PlannedTask | None = scheduler.get_task_by_uuid(task_uuid)
406 if not task:
407 return Response(message=f"Task not found: {task_uuid}", break_loop=False)
408
409 if task.context_id == self.agent.context.id:
410 return Response(message="You can only wait for tasks running in their own dedicated context.", break_loop=False)
411
412 done = False
413 elapsed = 0
414 while not done:
415 await scheduler.reload()
416 task = scheduler.get_task_by_uuid(task_uuid)
417 if not task:
418 return Response(message=f"Task not found: {task_uuid}", break_loop=False)
419
420 if task.state == TaskState.RUNNING:
421 await asyncio.sleep(1)
422 elapsed += 1
423 if elapsed > DEFAULT_WAIT_TIMEOUT:
424 return Response(message=f"Task wait timeout ({DEFAULT_WAIT_TIMEOUT} seconds): {task_uuid}", break_loop=False)
425 else:
426 done = True
427
428 return Response(
429 message=f"*Task*: {task_uuid}\n*State*: {task.state}\n*Last run*: {serialize_datetime(task.last_run)}\n*Result*:\n{task.last_result}",
430 break_loop=False
431 )