Skip to content

Commit 942a26b

Browse files
committed
change to duration
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 6583f18 commit 942a26b

5 files changed

Lines changed: 190 additions & 52 deletions

File tree

core/action/reminder.go

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,27 @@ import (
1212
)
1313

1414
const (
15+
RecurringReminderActionName = "set_recurring_reminder"
16+
OneTimeReminderActionName = "set_onetime_reminder"
17+
ListRemindersName = "list_reminders"
18+
RemoveReminderName = "remove_reminder"
19+
20+
// Deprecated: use RecurringReminderActionName or OneTimeReminderActionName
1521
ReminderActionName = "set_reminder"
16-
ListRemindersName = "list_reminders"
17-
RemoveReminderName = "remove_reminder"
1822
)
1923

20-
func NewReminder() *ReminderAction {
21-
return &ReminderAction{}
24+
func NewRecurringReminder() *RecurringReminderAction {
25+
return &RecurringReminderAction{}
26+
}
27+
28+
func NewOneTimeReminder() *OneTimeReminderAction {
29+
return &OneTimeReminderAction{}
30+
}
31+
32+
// NewReminder returns a RecurringReminderAction for backward compatibility.
33+
// Deprecated: use NewRecurringReminder or NewOneTimeReminder.
34+
func NewReminder() *RecurringReminderAction {
35+
return &RecurringReminderAction{}
2236
}
2337

2438
func NewListReminders() *ListRemindersAction {
@@ -29,38 +43,32 @@ func NewRemoveReminder() *RemoveReminderAction {
2943
return &RemoveReminderAction{}
3044
}
3145

32-
type ReminderAction struct{}
46+
type RecurringReminderAction struct{}
47+
type OneTimeReminderAction struct{}
3348
type ListRemindersAction struct{}
3449
type RemoveReminderAction struct{}
3550

3651
type RemoveReminderParams struct {
3752
Index int `json:"index"`
3853
}
3954

40-
func (a *ReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
41-
result := types.ReminderActionResponse{}
55+
func (a *RecurringReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
56+
result := types.RecurringReminderParams{}
4257
err := params.Unmarshal(&result)
4358
if err != nil {
4459
return types.ActionResult{}, err
4560
}
4661

47-
// Create a scheduler task
48-
scheduleType := scheduler.ScheduleTypeCron
49-
if !result.IsRecurring {
50-
scheduleType = scheduler.ScheduleTypeOnce
51-
}
52-
5362
task, err := scheduler.NewTask(
5463
sharedState.AgentName,
5564
result.Message,
56-
scheduleType,
65+
scheduler.ScheduleTypeCron,
5766
result.CronExpr,
5867
)
5968
if err != nil {
6069
return types.ActionResult{}, err
6170
}
6271

63-
// Store task ID in metadata for tracking
6472
task.Metadata["reminder_type"] = "user_created"
6573

6674
err = sharedState.Scheduler.CreateTask(task)
@@ -69,15 +77,53 @@ func (a *ReminderAction) Run(ctx context.Context, sharedState *types.AgentShared
6977
}
7078

7179
return types.ActionResult{
72-
Result: fmt.Sprintf("Reminder set successfully (ID: %s)", task.ID),
80+
Result: fmt.Sprintf("Recurring reminder set successfully (ID: %s). Next run: %s", task.ID, task.NextRun.Format(time.RFC3339)),
7381
Metadata: map[string]interface{}{
74-
"task_id": task.ID,
75-
"message": result.Message,
76-
"next_run": task.NextRun,
77-
"recurring": result.IsRecurring,
82+
"task_id": task.ID,
83+
"message": result.Message,
84+
"next_run": task.NextRun,
7885
},
7986
}, nil
87+
}
88+
89+
func (a *OneTimeReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
90+
result := types.OneTimeReminderParams{}
91+
err := params.Unmarshal(&result)
92+
if err != nil {
93+
return types.ActionResult{}, err
94+
}
8095

96+
// Validate the delay parses correctly before creating the task
97+
_, err = scheduler.ParseDuration(result.Delay)
98+
if err != nil {
99+
return types.ActionResult{}, fmt.Errorf("invalid delay format, expected a duration like '30m', '2h', '1d', '1d12h': %w", err)
100+
}
101+
102+
task, err := scheduler.NewTask(
103+
sharedState.AgentName,
104+
result.Message,
105+
scheduler.ScheduleTypeOnce,
106+
result.Delay,
107+
)
108+
if err != nil {
109+
return types.ActionResult{}, err
110+
}
111+
112+
task.Metadata["reminder_type"] = "user_created"
113+
114+
err = sharedState.Scheduler.CreateTask(task)
115+
if err != nil {
116+
return types.ActionResult{}, err
117+
}
118+
119+
return types.ActionResult{
120+
Result: fmt.Sprintf("One-time reminder set in %s (at %s, ID: %s)", result.Delay, task.NextRun.Format(time.RFC3339), task.ID),
121+
Metadata: map[string]interface{}{
122+
"task_id": task.ID,
123+
"message": result.Message,
124+
"next_run": task.NextRun,
125+
},
126+
}, nil
81127
}
82128

83129
func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
@@ -155,7 +201,11 @@ func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.Agent
155201
}, nil
156202
}
157203

158-
func (a *ReminderAction) Plannable() bool {
204+
func (a *RecurringReminderAction) Plannable() bool {
205+
return true
206+
}
207+
208+
func (a *OneTimeReminderAction) Plannable() bool {
159209
return true
160210
}
161211

@@ -167,10 +217,10 @@ func (a *RemoveReminderAction) Plannable() bool {
167217
return true
168218
}
169219

170-
func (a *ReminderAction) Definition() types.ActionDefinition {
220+
func (a *RecurringReminderAction) Definition() types.ActionDefinition {
171221
return types.ActionDefinition{
172-
Name: ReminderActionName,
173-
Description: "Set a reminder for the agent to wake up and perform a task based on a cron schedule. Examples: '0 0 * * *' (daily at midnight), '0 */2 * * *' (every 2 hours), '0 0 * * 1' (every Monday at midnight)",
222+
Name: RecurringReminderActionName,
223+
Description: "Set a recurring reminder for the agent to wake up and perform a task on a cron schedule. The reminder will keep repeating. Examples: '0 0 * * *' (daily at midnight), '0 */2 * * *' (every 2 hours), '0 0 * * 1' (every Monday at midnight)",
174224
Properties: map[string]jsonschema.Definition{
175225
"message": {
176226
Type: jsonschema.String,
@@ -180,12 +230,26 @@ func (a *ReminderAction) Definition() types.ActionDefinition {
180230
Type: jsonschema.String,
181231
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'minute hour day month weekday'",
182232
},
183-
"is_recurring": {
184-
Type: jsonschema.Boolean,
185-
Description: "Whether this reminder should repeat according to the cron schedule (true) or trigger only once (false)",
233+
},
234+
Required: []string{"message", "cron_expr"},
235+
}
236+
}
237+
238+
func (a *OneTimeReminderAction) Definition() types.ActionDefinition {
239+
return types.ActionDefinition{
240+
Name: OneTimeReminderActionName,
241+
Description: "Set a one-time reminder for the agent to wake up and perform a task after a delay. The reminder triggers only once and is then automatically removed. Use this when asked to do something 'in X minutes/hours/days'. Examples: '30m' (30 minutes), '2h' (2 hours), '1d' (1 day), '1d12h' (1.5 days), '2h30m' (2.5 hours)",
242+
Properties: map[string]jsonschema.Definition{
243+
"message": {
244+
Type: jsonschema.String,
245+
Description: "The message or task to be reminded about",
246+
},
247+
"delay": {
248+
Type: jsonschema.String,
249+
Description: "How long to wait before triggering. Use Go duration format: '30m' (30 minutes), '2h' (2 hours), '1d' (1 day), '1d12h' (1.5 days), '2h30m' (2.5 hours)",
186250
},
187251
},
188-
Required: []string{"message", "cron_expr", "is_recurring"},
252+
Required: []string{"message", "delay"},
189253
}
190254
}
191255

core/scheduler/scheduler_test.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,27 @@ var _ = Describe("Scheduler", func() {
7878
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(time.Hour), 5*time.Second))
7979
})
8080

81-
It("should create a valid task with once schedule", func() {
82-
futureTime := time.Now().Add(24 * time.Hour)
83-
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, futureTime.Format(time.RFC3339))
81+
It("should create a valid task with once schedule using duration", func() {
82+
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "24h")
8483
Expect(err).NotTo(HaveOccurred())
85-
Expect(task.NextRun).To(BeTemporally("~", futureTime, time.Second))
84+
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(24*time.Hour), 5*time.Second))
85+
})
86+
87+
It("should create a valid task with once schedule using day syntax", func() {
88+
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "1d")
89+
Expect(err).NotTo(HaveOccurred())
90+
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(24*time.Hour), 5*time.Second))
91+
})
92+
93+
It("should create a valid task with once schedule using combined day+time", func() {
94+
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "2d12h30m")
95+
Expect(err).NotTo(HaveOccurred())
96+
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(2*24*time.Hour+12*time.Hour+30*time.Minute), 5*time.Second))
97+
})
98+
99+
It("should return error for invalid once duration", func() {
100+
_, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "invalid")
101+
Expect(err).To(HaveOccurred())
86102
})
87103
})
88104

@@ -161,11 +177,15 @@ var _ = Describe("Scheduler", func() {
161177
Context("Querying tasks", func() {
162178
BeforeEach(func() {
163179
// Create test tasks
164-
task1, err := scheduler.NewTask("agent1", "prompt1", scheduler.ScheduleTypeOnce, time.Now().Add(-1*time.Hour).Format(time.RFC3339))
180+
// task1: once schedule with 0s delay => immediately due
181+
task1, err := scheduler.NewTask("agent1", "prompt1", scheduler.ScheduleTypeOnce, "0s")
165182
Expect(err).NotTo(HaveOccurred())
166-
task2, err := scheduler.NewTask("agent2", "prompt2", scheduler.ScheduleTypeOnce, time.Now().Add(1*time.Hour).Format(time.RFC3339))
183+
task1.NextRun = time.Now().Add(-1 * time.Hour) // force into the past
184+
// task2: cron schedule => next run in the future, not due
185+
task2, err := scheduler.NewTask("agent2", "prompt2", scheduler.ScheduleTypeCron, "0 0 1 1 *")
167186
Expect(err).NotTo(HaveOccurred())
168-
task3, err := scheduler.NewTask("agent1", "prompt3", scheduler.ScheduleTypeOnce, time.Now().Add(-1*time.Hour).Format(time.RFC3339))
187+
// task3: once schedule but paused => not due
188+
task3, err := scheduler.NewTask("agent1", "prompt3", scheduler.ScheduleTypeOnce, "0s")
169189
Expect(err).NotTo(HaveOccurred())
170190
task3.Status = scheduler.TaskStatusPaused
171191

@@ -251,7 +271,8 @@ var _ = Describe("Scheduler", func() {
251271

252272
Describe("Scheduler Execution", func() {
253273
It("should execute a due task", func() {
254-
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, time.Now().Add(-1*time.Second).Format(time.RFC3339))
274+
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "0s")
275+
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
255276
err := sched.CreateTask(task)
256277
Expect(err).NotTo(HaveOccurred())
257278

@@ -294,7 +315,8 @@ var _ = Describe("Scheduler", func() {
294315

295316
It("should handle task execution errors", func() {
296317
executor.shouldError = true
297-
task, _ := scheduler.NewTask("test-agent", "error task", scheduler.ScheduleTypeOnce, time.Now().Add(-1*time.Second).Format(time.RFC3339))
318+
task, _ := scheduler.NewTask("test-agent", "error task", scheduler.ScheduleTypeOnce, "0s")
319+
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
298320
sched.CreateTask(task)
299321

300322
// Scheduler is already started in BeforeEach
@@ -310,7 +332,8 @@ var _ = Describe("Scheduler", func() {
310332
})
311333

312334
It("should not execute paused tasks", func() {
313-
task, _ := scheduler.NewTask("test-agent", "paused", scheduler.ScheduleTypeOnce, time.Now().Add(-1*time.Second).Format(time.RFC3339))
335+
task, _ := scheduler.NewTask("test-agent", "paused", scheduler.ScheduleTypeOnce, "0s")
336+
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
314337
task.Status = scheduler.TaskStatusPaused
315338
sched.CreateTask(task)
316339

core/scheduler/task.go

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,37 @@ package scheduler
22

33
import (
44
"fmt"
5+
"regexp"
56
"strconv"
67
"time"
78

89
"github.com/google/uuid"
910
"github.com/robfig/cron/v3"
1011
)
1112

13+
var dayPattern = regexp.MustCompile(`^(\d+)d(.*)$`)
14+
15+
// ParseDuration extends time.ParseDuration with support for days ("d").
16+
// Examples: "1d" = 24h, "2d12h" = 60h, "30m", "2h30m".
17+
func ParseDuration(s string) (time.Duration, error) {
18+
if m := dayPattern.FindStringSubmatch(s); m != nil {
19+
days, err := strconv.Atoi(m[1])
20+
if err != nil {
21+
return 0, fmt.Errorf("invalid duration: %s", s)
22+
}
23+
d := time.Duration(days) * 24 * time.Hour
24+
if m[2] != "" {
25+
rest, err := time.ParseDuration(m[2])
26+
if err != nil {
27+
return 0, fmt.Errorf("invalid duration: %w", err)
28+
}
29+
d += rest
30+
}
31+
return d, nil
32+
}
33+
return time.ParseDuration(s)
34+
}
35+
1236
type TaskStatus string
1337

1438
const (
@@ -78,7 +102,7 @@ func (t *Task) CalculateNextRun() error {
78102
now := time.Now()
79103

80104
switch t.ScheduleType {
81-
case ScheduleTypeCron, ScheduleTypeOnce:
105+
case ScheduleTypeCron:
82106
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
83107
schedule, err := parser.Parse(t.ScheduleValue)
84108
if err != nil {
@@ -100,12 +124,15 @@ func (t *Task) CalculateNextRun() error {
100124
t.NextRun = now.Add(time.Duration(intervalMs) * time.Millisecond)
101125
}
102126

103-
// case ScheduleTypeOnce:
104-
// nextRun, err := time.Parse(time.RFC3339, t.ScheduleValue)
105-
// if err != nil {
106-
// return fmt.Errorf("invalid timestamp: %w", err)
107-
// }
108-
// t.NextRun = nextRun
127+
case ScheduleTypeOnce:
128+
duration, err := ParseDuration(t.ScheduleValue)
129+
if err != nil {
130+
return fmt.Errorf("invalid duration: %w", err)
131+
}
132+
if duration < 0 {
133+
return fmt.Errorf("duration must be positive: %s", t.ScheduleValue)
134+
}
135+
t.NextRun = now.Add(duration)
109136

110137
default:
111138
return fmt.Errorf("unknown schedule type: %s", t.ScheduleType)

core/types/state.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,22 @@ const (
4040
DefaultLastMessageDuration = 5 * time.Minute
4141
)
4242

43-
type ReminderActionResponse struct {
44-
Message string `json:"message"`
45-
CronExpr string `json:"cron_expr"` // Cron expression for scheduling
46-
LastRun time.Time `json:"last_run"` // Last time this reminder was triggered
47-
NextRun time.Time `json:"next_run"` // Next scheduled run time
48-
IsRecurring bool `json:"is_recurring"` // Whether this is a recurring reminder
43+
// RecurringReminderParams are the parameters the LLM provides for set_recurring_reminder.
44+
type RecurringReminderParams struct {
45+
Message string `json:"message"`
46+
CronExpr string `json:"cron_expr"`
4947
}
5048

49+
// OneTimeReminderParams are the parameters the LLM provides for set_onetime_reminder.
50+
type OneTimeReminderParams struct {
51+
Message string `json:"message"`
52+
Delay string `json:"delay"` // Go duration format with day support: "30m", "2h", "1d", "1d12h"
53+
}
54+
55+
// ReminderActionResponse is kept for backward compatibility.
56+
// Deprecated: use RecurringReminderParams or OneTimeReminderParams.
57+
type ReminderActionResponse = RecurringReminderParams
58+
5159
type AgentSharedState struct {
5260
ConversationTracker *conversations.ConversationTracker[string] `json:"conversation_tracker"`
5361
Scheduler TaskScheduler `json:"-"` // Not serialized, set at runtime

0 commit comments

Comments
 (0)