Skip to content

feat(reminders): add reminder system to perform long-term goals in the background #176

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions core/action/reminder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package action

import (
"context"
"fmt"
"strings"
"time"

"github.com/mudler/LocalAGI/core/types"
"github.com/robfig/cron/v3"
"github.com/sashabaranov/go-openai/jsonschema"
)

const (
ReminderActionName = "set_reminder"
ListRemindersName = "list_reminders"
RemoveReminderName = "remove_reminder"
)

func NewReminder() *ReminderAction {
return &ReminderAction{}
}

func NewListReminders() *ListRemindersAction {
return &ListRemindersAction{}
}

func NewRemoveReminder() *RemoveReminderAction {
return &RemoveReminderAction{}
}

type ReminderAction struct{}
type ListRemindersAction struct{}
type RemoveReminderAction struct{}

type RemoveReminderParams struct {
Index int `json:"index"`
}

func (a *ReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := types.ReminderActionResponse{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, err
}

// Validate the cron expression
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
_, err = parser.Parse(result.CronExpr)
if err != nil {
return types.ActionResult{}, err
}

// Calculate next run time
now := time.Now()
schedule, _ := parser.Parse(result.CronExpr) // We can ignore the error since we validated above
nextRun := schedule.Next(now)

// Set the reminder details
result.LastRun = now
result.NextRun = nextRun
// IsRecurring is set by the user through the action parameters

// Store the reminder in the shared state
if sharedState.Reminders == nil {
sharedState.Reminders = make([]types.ReminderActionResponse, 0)
}
sharedState.Reminders = append(sharedState.Reminders, result)

return types.ActionResult{
Result: "Reminder set successfully",
Metadata: map[string]interface{}{
"reminder": result,
},
}, nil
}

func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
return types.ActionResult{
Result: "No reminders set",
}, nil
}

var result strings.Builder
result.WriteString("Current reminders:\n")
for i, reminder := range sharedState.Reminders {
status := "one-time"
if reminder.IsRecurring {
status = "recurring"
}
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s)\n",
i+1,
reminder.Message,
reminder.NextRun.Format(time.RFC3339),
status))
}

return types.ActionResult{
Result: result.String(),
Metadata: map[string]interface{}{
"reminders": sharedState.Reminders,
},
}, nil
}

func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
var removeParams RemoveReminderParams
err := params.Unmarshal(&removeParams)
if err != nil {
return types.ActionResult{}, err
}

if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
return types.ActionResult{
Result: "No reminders to remove",
}, nil
}

// Convert from 1-based index to 0-based
index := removeParams.Index - 1
if index < 0 || index >= len(sharedState.Reminders) {
return types.ActionResult{}, fmt.Errorf("invalid reminder index: %d", removeParams.Index)
}

// Remove the reminder
removed := sharedState.Reminders[index]
sharedState.Reminders = append(sharedState.Reminders[:index], sharedState.Reminders[index+1:]...)

return types.ActionResult{
Result: fmt.Sprintf("Removed reminder: %s", removed.Message),
Metadata: map[string]interface{}{
"removed_reminder": removed,
},
}, nil
}

func (a *ReminderAction) Plannable() bool {
return true
}

func (a *ListRemindersAction) Plannable() bool {
return true
}

func (a *RemoveReminderAction) Plannable() bool {
return true
}

func (a *ReminderAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: ReminderActionName,
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)",
Properties: map[string]jsonschema.Definition{
"message": {
Type: jsonschema.String,
Description: "The message or task to be reminded about",
},
"cron_expr": {
Type: jsonschema.String,
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'second minute hour day month weekday'",
},
"is_recurring": {
Type: jsonschema.Boolean,
Description: "Whether this reminder should repeat according to the cron schedule (true) or trigger only once (false)",
},
},
Required: []string{"message", "cron_expr", "is_recurring"},
}
}

func (a *ListRemindersAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: ListRemindersName,
Description: "List all currently set reminders with their next scheduled run times",
Properties: map[string]jsonschema.Definition{},
Required: []string{},
}
}

func (a *RemoveReminderAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: RemoveReminderName,
Description: "Remove a reminder by its index number (use list_reminders to see the index)",
Properties: map[string]jsonschema.Definition{
"index": {
Type: jsonschema.Integer,
Description: "The index number of the reminder to remove (1-based)",
},
},
Required: []string{"index"},
}
}
8 changes: 8 additions & 0 deletions core/agent/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ func (m Messages) IsLastMessageFromRole(role string) bool {
}

func (a *Agent) generateParameters(job *types.Job, pickTemplate string, act types.Action, c []openai.ChatCompletionMessage, reasoning string, maxAttempts int) (*decisionResult, error) {

if len(act.Definition().Properties) > 0 {
xlog.Debug("Action has properties", "action", act.Definition().Name, "properties", act.Definition().Properties)
} else {
xlog.Debug("Action has no properties", "action", act.Definition().Name)
return &decisionResult{actionParams: types.ActionParams{}}, nil
}

stateHUD, err := renderTemplate(pickTemplate, a.prepareHUD(), a.availableActions(), reasoning)
if err != nil {
return nil, err
Expand Down
123 changes: 78 additions & 45 deletions core/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/mudler/LocalAGI/core/action"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/llm"
"github.com/robfig/cron/v3"
"github.com/sashabaranov/go-openai"
)

Expand Down Expand Up @@ -1026,25 +1027,83 @@ func (a *Agent) periodicallyRun(timer *time.Timer) {

xlog.Debug("Agent is running periodically", "agent", a.Character.Name)

// TODO: Would be nice if we have a special action to
// contact the user. This would actually make sure that
// if the agent wants to initiate a conversation, it can do so.
// This would be a special action that would be picked up by the agent
// and would be used to contact the user.

// if len(conv()) != 0 {
// // Here the LLM could decide to store some part of the conversation too in the memory
// evaluateMemory := NewJob(
// WithText(
// `Evaluate the current conversation and decide if we need to store some relevant informations from it`,
// ),
// WithReasoningCallback(a.options.reasoningCallback),
// WithResultCallback(a.options.resultCallback),
// )
// a.consumeJob(evaluateMemory, SystemRole)

// a.ResetConversation()
// }
// Check for reminders that need to be triggered
now := time.Now()
var triggeredReminders []types.ReminderActionResponse
var remainingReminders []types.ReminderActionResponse

for _, reminder := range a.sharedState.Reminders {
xlog.Debug("Checking reminder", "reminder", reminder)
if now.After(reminder.NextRun) {
triggeredReminders = append(triggeredReminders, reminder)
xlog.Debug("Reminder triggered", "reminder", reminder)
// Calculate next run time for recurring reminders
if reminder.IsRecurring {
xlog.Debug("Reminder is recurring", "reminder", reminder)
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
schedule, err := parser.Parse(reminder.CronExpr)
if err == nil {
nextRun := schedule.Next(now)
xlog.Debug("Next run time", "reminder", reminder, "nextRun", nextRun)
reminder.LastRun = now
reminder.NextRun = nextRun
remainingReminders = append(remainingReminders, reminder)
}
}
} else {
xlog.Debug("Reminder not triggered", "reminder", reminder)
remainingReminders = append(remainingReminders, reminder)
}
}

// Update the reminders list
a.sharedState.Reminders = remainingReminders

// Handle triggered reminders
for _, reminder := range triggeredReminders {
xlog.Info("Processing triggered reminder", "agent", a.Character.Name, "message", reminder.Message)

// Create a more natural conversation flow for the reminder
reminderJob := types.NewJob(
types.WithText(fmt.Sprintf("I have a reminder for you: %s", reminder.Message)),
types.WithReasoningCallback(a.options.reasoningCallback),
types.WithResultCallback(a.options.resultCallback),
)

// Add the reminder message to the job's metadata
reminderJob.Metadata = map[string]interface{}{
"message": reminder.Message,
"is_reminder": true,
}

// Process the reminder as a normal conversation
a.consumeJob(reminderJob, UserRole, a.options.loopDetectionSteps)

// After the reminder job is complete, ensure the user is notified
if reminderJob.Result != nil && reminderJob.Result.Conversation != nil {
// Get the last assistant message from the conversation
var lastAssistantMsg *openai.ChatCompletionMessage
for i := len(reminderJob.Result.Conversation) - 1; i >= 0; i-- {
if reminderJob.Result.Conversation[i].Role == AssistantRole {
lastAssistantMsg = &reminderJob.Result.Conversation[i]
break
}
}

if lastAssistantMsg != nil && lastAssistantMsg.Content != "" {
// Send the reminder response to the user
msg := openai.ChatCompletionMessage{
Role: "assistant",
Content: fmt.Sprintf("Reminder Update: %s\n\n%s", reminder.Message, lastAssistantMsg.Content),
}

go func(agent *Agent) {
xlog.Info("Sending reminder response to user", "agent", agent.Character.Name, "message", msg.Content)
agent.newConversations <- msg
}(a)
}
}
}

if !a.options.standaloneJob {
return
Expand All @@ -1056,7 +1115,6 @@ func (a *Agent) periodicallyRun(timer *time.Timer) {
// - evaluating the result
// - asking the agent to do something else based on the result

// whatNext := NewJob(WithText("Decide what to do based on the state"))
whatNext := types.NewJob(
types.WithText(innerMonologueTemplate),
types.WithReasoningCallback(a.options.reasoningCallback),
Expand All @@ -1065,31 +1123,6 @@ func (a *Agent) periodicallyRun(timer *time.Timer) {
a.consumeJob(whatNext, SystemRole, a.options.loopDetectionSteps)

xlog.Info("STOP -- Periodically run is done", "agent", a.Character.Name)

// Save results from state

// a.ResetConversation()

// doWork := NewJob(WithText("Select the tool to use based on your goal and the current state."))
// a.consumeJob(doWork, SystemRole)

// results := []string{}
// for _, v := range doWork.Result.State {
// results = append(results, v.Result)
// }

// a.ResetConversation()

// // Here the LLM could decide to do something based on the result of our automatic action
// evaluateAction := NewJob(
// WithText(
// `Evaluate the current situation and decide if we need to execute other tools (for instance to store results into permanent, or short memory).
// We have done the following actions:
// ` + strings.Join(results, "\n"),
// ))
// a.consumeJob(evaluateAction, SystemRole)

// a.ResetConversation()
}

func (a *Agent) Run() error {
Expand Down
10 changes: 10 additions & 0 deletions core/types/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,17 @@ const (
DefaultLastMessageDuration = 5 * time.Minute
)

type ReminderActionResponse struct {
Message string `json:"message"`
CronExpr string `json:"cron_expr"` // Cron expression for scheduling
LastRun time.Time `json:"last_run"` // Last time this reminder was triggered
NextRun time.Time `json:"next_run"` // Next scheduled run time
IsRecurring bool `json:"is_recurring"` // Whether this is a recurring reminder
}

type AgentSharedState struct {
ConversationTracker *conversations.ConversationTracker[string] `json:"conversation_tracker"`
Reminders []ReminderActionResponse `json:"reminders"`
}

func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
Expand All @@ -39,6 +48,7 @@ func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
}
return &AgentSharedState{
ConversationTracker: conversations.NewConversationTracker[string](lastMessageDuration),
Reminders: make([]ReminderActionResponse, 0),
}
}

Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ require (
mvdan.cc/xurls/v2 v2.6.0
)

require github.com/JohannesKaufmann/dom v0.2.0 // indirect
require (
github.com/JohannesKaufmann/dom v0.2.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
)

require (
Comment on lines 33 to 40
Copy link
Preview

Copilot AI May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] You have multiple require blocks—consider consolidating them into one to improve readability of module dependencies.

Copilot uses AI. Check for mistakes.

github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.2
Expand Down
Loading
Loading