Scheduler wait_for_task and find_task_by_name (#388)
* feat: scheduler.wait_for_task tool and context param for scheduler.run_task tool * fix: Tasks in same context are not waitable * feat: scheduler.find_task_by_name * fix: find_task_by_name case insensitive --------- Co-authored-by: Rafael Uzarowski <uzarowski.rafael@proton.me>
ehl0wr0ld committed
May 19, 2025 at 14:46 UTC
7675e7e804b6d3877d909d91dac6159676c8447c
3 files changed
+126
-15
prompts/default/agent.system.tool.scheduler.md
+48
-2
@@ -11,10 +11,12 @@ dedicated_context flag then the task will run in the chat it was created in incl
11
There are manual and automatically executed tasks.
12
Automatic execution happens by a schedule defined when creating the task.
13
14
+Tasks are run asynchronously. If you need to wait for a running task's completion or need the result of the last task run, use the scheduler:wait_for_task tool. It will wait for the task completion in case the task is currently running and will provide the result of the last execution.
15
+
16
### Important instructions
17
When a task is scheduled or planned, do not manually run it, if you have no more tasks, respond to user.
18
Be careful not to create recursive prompt, do not send a message that would make the agent schedule more tasks, no need to mention the interval in message, just the objective.
17
-
19
+!!! When the user asks you to execute a task, first check if the task already exists and do not create a new task for execution. Execute the existing task instead. If the task in question does not exist ask the user what action to take. Never create tasks if asked to execute a task.
20
21
### Types of scheduler tasks
22
There are 3 types of scheduler tasks:
@@ -34,7 +36,7 @@ This type of task is run manually and does not follow any schedule. It can be ru
36
37
#### scheduler:list_tasks
38
List all tasks present in the system with their 'uuid', 'name', 'type', 'state', 'schedule' and 'next_run'.
37
-All runnable tasks can be listed and filtered here. The arbuments a filter fields.
39
+All runnable tasks can be listed and filtered here. The arguments are filter fields.
40
41
##### Arguments:
42
* state: list(str) (Optional) - The state filter, one of "idle", "running", "disabled", "error". To only show tasks in given state.
@@ -59,6 +61,26 @@ All runnable tasks can be listed and filtered here. The arbuments a filter field
61
~~~
62
63
64
+#### scheduler:find_task_by_name
65
+List all tasks whose name is matching partially or fully the provided name parameter.
66
+
67
+##### Arguments:
68
+* name: str - The task name to look for
69
+
70
+##### Usage:
71
+~~~json
72
+{
73
+ "thoughts": [
74
+ "I must look for tasks with name XYZ"
75
+ ],
76
+ "tool_name": "scheduler:find_task_by_name",
77
+ "tool_args": {
78
+ "name": "XYZ"
79
+ }
80
+}
81
+~~~
82
+
83
+
84
#### scheduler:show_task
85
Show task details for scheduler task with the given uuid.
86
@@ -84,9 +106,11 @@ Execute a task manually which is not in "running" state
106
This can be used to trigger tasks manually.
107
Normally you should only "run" tasks manually if they are in the "idle" state.
108
It is also advised to only run "adhoc" tasks manually but every task type can be triggered by this tool.
109
+You can pass input data in text form as the "context" argument. The context will then be prepended to the task prompt when executed. This way you can pass for example result of one task as the input of another task or provide additional information specific to this one task run.
110
111
##### Arguments:
112
* uuid: string - The uuid of the task to run. Can be retrieved for example from "scheduler:tasks_list"
113
+* context: (Optional) string - The context that will be prepended to the actual task prompt as contextual information.
114
115
##### Usage (execute task with uuid "xyz-123"):
116
~~~json
@@ -97,6 +121,7 @@ It is also advised to only run "adhoc" tasks manually but every task type can be
121
"tool_name": "scheduler:run_task",
122
"tool_args": {
123
"uuid": "xyz-123",
124
+ "context": "This text is useful to execute the task more precisely"
125
}
126
}
127
~~~
@@ -218,3 +243,24 @@ The planned type of tasks is being run by a fixed plan, a list of datetimes that
243
}
244
}
245
~~~
246
+
247
+
248
+#### scheduler:wait_for_task
249
+Wait for the completion of a scheduler task identified by the uuid argument and return the result of last execution of the task.
250
+Attention: You can only wait for tasks running in a different chat context (dedicated). Tasks with dedicated_context=False can not be waited for.
251
+
252
+##### Arguments:
253
+* uuid: string - The uuid of the task to wait for. Can be retrieved for example from "scheduler:tasks_list"
254
+
255
+##### Usage (wait for task with uuid "xyz-123"):
256
+~~~json
257
+{
258
+ "thoughts": [
259
+ "I need the most current result of the task xyz-123",
260
+ ],
261
+ "tool_name": "scheduler:wait_for_task",
262
+ "tool_args": {
263
+ "uuid": "xyz-123",
264
+ }
265
+}
266
+~~~
python/helpers/task_scheduler.py
+23
-10
@@ -579,6 +579,10 @@ class SchedulerTaskList(BaseModel):
579
with self._lock:
580
return next((task for task in self.tasks if task.name == name), None)
581
582
+ def find_task_by_name(self, name: str) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]:
583
+ with self._lock:
584
+ return [task for task in self.tasks if name.lower() in task.name.lower()]
585
+
586
async def remove_task_by_uuid(self, task_uuid: str) -> "SchedulerTaskList":
587
with self._lock:
588
self.tasks = [task for task in self.tasks if task.uuid != task_uuid]
@@ -622,7 +626,7 @@ class TaskScheduler:
626
627
async def add_task(self, task: Union[ScheduledTask, AdHocTask, PlannedTask]) -> "TaskScheduler":
628
await self._tasks.add_task(task)
625
- ctx = await self._get_chat_context(task) # invoke context creation
629
+ ctx = await self._get_chat_context(task) # invoke context creation
630
return self
631
632
async def remove_task_by_uuid(self, task_uuid: str) -> "TaskScheduler":
@@ -639,11 +643,14 @@ class TaskScheduler:
643
def get_task_by_name(self, name: str) -> Union[ScheduledTask, AdHocTask, PlannedTask] | None:
644
return self._tasks.get_task_by_name(name)
645
646
+ def find_task_by_name(self, name: str) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]:
647
+ return self._tasks.find_task_by_name(name)
648
+
649
async def tick(self):
650
for task in await self._tasks.get_due_tasks():
651
await self._run_task(task)
652
646
- async def run_task_by_uuid(self, task_uuid: str):
653
+ async def run_task_by_uuid(self, task_uuid: str, task_context: str | None = None):
654
# First reload tasks to ensure we have the latest state
655
await self._tasks.reload()
656
@@ -671,13 +678,13 @@ class TaskScheduler:
678
raise ValueError(f"Task with UUID '{task_uuid}' not found after state reset")
679
680
# Run the task
674
- await self._run_task(task)
681
+ await self._run_task(task, task_context)
682
676
- async def run_task_by_name(self, name: str):
683
+ async def run_task_by_name(self, name: str, task_context: str | None = None):
684
task = self._tasks.get_task_by_name(name)
685
if task is None:
686
raise ValueError(f"Task with name {name} not found")
680
- await self._run_task(task)
687
+ await self._run_task(task, task_context)
688
689
async def save(self):
690
await self._tasks.save()
@@ -737,9 +744,9 @@ class TaskScheduler:
744
raise ValueError(f"Context ID mismatch for task {task.name}: context {context.id} != task {task.context_id}")
745
save_tmp_chat(context)
746
740
- async def _run_task(self, task: Union[ScheduledTask, AdHocTask, PlannedTask]):
747
+ async def _run_task(self, task: Union[ScheduledTask, AdHocTask, PlannedTask], task_context: str | None = None):
748
742
- async def _run_task_wrapper(task_uuid: str):
749
+ async def _run_task_wrapper(task_uuid: str, task_context: str | None = None):
750
751
# preflight checks with a snapshot of the task
752
task_snapshot: Union[ScheduledTask, AdHocTask, PlannedTask] | None = self.get_task_by_uuid(task_uuid)
@@ -799,18 +806,24 @@ class TaskScheduler:
806
for filename in attachment_filenames:
807
self._printer.print(f"- {filename}")
808
809
+ task_prompt = f"# Starting scheduler task '{current_task.name}' ({current_task.uuid})"
810
+ if task_context:
811
+ task_prompt = f"## Context:\n{task_context}\n\n## Task:\n{current_task.prompt}"
812
+ else:
813
+ task_prompt = f"## Task:\n{current_task.prompt}"
814
+
815
# Log the message with message_id and attachments
816
context.log.log(
817
type="user",
818
heading="User message",
806
- content=current_task.prompt,
819
+ content=task_prompt,
820
kvps={"attachments": attachment_filenames},
821
id=str(uuid.uuid4()),
822
)
823
824
agent.hist_add_user_message(
825
UserMessage(
813
- message=current_task.prompt,
826
+ message=task_prompt,
827
system_message=[current_task.system_prompt],
828
attachments=attachment_filenames))
829
@@ -854,7 +867,7 @@ class TaskScheduler:
867
await self._tasks.save()
868
869
deferred_task = DeferredTask(thread_name=self.__class__.__name__)
857
- deferred_task.start_task(_run_task_wrapper, task.uuid)
870
+ deferred_task.start_task(_run_task_wrapper, task.uuid, task_context)
871
872
# Ensure background execution doesn't exit immediately on async await, especially in script contexts
873
# This helps prevent premature exits when running from non-event-loop contexts
python/tools/scheduler.py
+55
-3
@@ -1,20 +1,26 @@
1
+import asyncio
2
from datetime import datetime
3
import json
4
import random
5
import re
6
from python.helpers.tool import Tool, Response
7
from python.helpers.task_scheduler import (
7
- TaskScheduler, ScheduledTask, AdHocTask, PlannedTask, serialize_task, TaskState, TaskSchedule, TaskPlan, parse_datetime
8
+ TaskScheduler, ScheduledTask, AdHocTask, PlannedTask,
9
+ serialize_task, TaskState, TaskSchedule, TaskPlan, parse_datetime, serialize_datetime
10
)
11
from agent import AgentContext
12
from python.helpers import persist_chat
13
14
+DEFAULT_WAIT_TIMEOUT = 300
15
+
16
17
class SchedulerTool(Tool):
18
19
async def execute(self, **kwargs):
20
if self.method == "list_tasks":
21
return await self.list_tasks(**kwargs)
22
+ elif self.method == "find_task_by_name":
23
+ return await self.find_task_by_name(**kwargs)
24
elif self.method == "show_task":
25
return await self.show_task(**kwargs)
26
elif self.method == "run_task":
@@ -27,6 +33,8 @@ class SchedulerTool(Tool):
33
return await self.create_adhoc_task(**kwargs)
34
elif self.method == "create_planned_task":
35
return await self.create_planned_task(**kwargs)
36
+ elif self.method == "wait_for_task":
37
+ return await self.wait_for_task(**kwargs)
38
else:
39
return Response(message=f"Unknown method '{self.name}:{self.method}'", break_loop=False)
40
@@ -51,6 +59,15 @@ class SchedulerTool(Tool):
59
60
return Response(message=json.dumps(filtered_tasks, indent=4), break_loop=False)
61
62
+ async def find_task_by_name(self, **kwargs) -> Response:
63
+ name: str = kwargs.get("name", None)
64
+ if not name:
65
+ return Response(message="Task name is required", break_loop=False)
66
+ tasks: list[ScheduledTask | AdHocTask | PlannedTask] = TaskScheduler.get().find_task_by_name(name)
67
+ if not tasks:
68
+ return Response(message=f"Task not found: {name}", break_loop=False)
69
+ return Response(message=json.dumps([serialize_task(task) for task in tasks], indent=4), break_loop=False)
70
+
71
async def show_task(self, **kwargs) -> Response:
72
task_uuid: str = kwargs.get("uuid", None)
73
if not task_uuid:
@@ -64,12 +81,13 @@ class SchedulerTool(Tool):
81
task_uuid: str = kwargs.get("uuid", None)
82
if not task_uuid:
83
return Response(message="Task UUID is required", break_loop=False)
84
+ task_context: str | None = kwargs.get("context", None)
85
task: ScheduledTask | AdHocTask | PlannedTask | None = TaskScheduler.get().get_task_by_uuid(task_uuid)
86
if not task:
87
return Response(message=f"Task not found: {task_uuid}", break_loop=False)
70
- await TaskScheduler.get().run_task_by_uuid(task_uuid)
88
+ await TaskScheduler.get().run_task_by_uuid(task_uuid, task_context)
89
if task.context_id == self.agent.context.id:
72
- break_loop = True # break loop if task is running in the same context, otherwise it would start two conversations in one window
90
+ break_loop = True # break loop if task is running in the same context, otherwise it would start two conversations in one window
91
else:
92
break_loop = False
93
return Response(message=f"Task started: {task_uuid}", break_loop=break_loop)
@@ -199,3 +217,37 @@ class SchedulerTool(Tool):
217
)
218
await TaskScheduler.get().add_task(task)
219
return Response(message=f"Planned task '{name}' created: {task.uuid}", break_loop=False)
220
+
221
+ async def wait_for_task(self, **kwargs) -> Response:
222
+ task_uuid: str = kwargs.get("uuid", None)
223
+ if not task_uuid:
224
+ return Response(message="Task UUID is required", break_loop=False)
225
+
226
+ scheduler = TaskScheduler.get()
227
+ task: ScheduledTask | AdHocTask | PlannedTask | None = scheduler.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
+ if task.context_id == self.agent.context.id:
232
+ return Response(message="You can only wait for tasks running in a different chat context (dedicated_context=True).", break_loop=False)
233
+
234
+ done = False
235
+ elapsed = 0
236
+ while not done:
237
+ await scheduler.reload()
238
+ task = scheduler.get_task_by_uuid(task_uuid)
239
+ if not task:
240
+ return Response(message=f"Task not found: {task_uuid}", break_loop=False)
241
+
242
+ if task.state == TaskState.RUNNING:
243
+ await asyncio.sleep(1)
244
+ elapsed += 1
245
+ if elapsed > DEFAULT_WAIT_TIMEOUT:
246
+ return Response(message=f"Task wait timeout ({DEFAULT_WAIT_TIMEOUT} seconds): {task_uuid}", break_loop=False)
247
+ else:
248
+ done = True
249
+
250
+ return Response(
251
+ message=f"*Task*: {task_uuid}\n*State*: {task.state}\n*Last run*: {serialize_datetime(task.last_run)}\n*Result*:\n{task.last_result}",
252
+ break_loop=False
253
+ )