feat(reminders): add reminder system to perform long-term goals in the background (#176)
* feat(reminders): add self-ability to set reminders Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(reminders): surface reminders result to the user as new conversations Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Fixups * Subscribe all connectors to agents new messages Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Set reminders in the list * fix(telegram): do not always auth Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Small fixups * Improve UX Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
GitHub
parent
490bf998a4
commit
9a90153dc6
193
core/action/reminder.go
Normal file
193
core/action/reminder.go
Normal file
@@ -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"},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
// Check for reminders that need to be triggered
|
||||
now := time.Now()
|
||||
var triggeredReminders []types.ReminderActionResponse
|
||||
var remainingReminders []types.ReminderActionResponse
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// a.ResetConversation()
|
||||
// }
|
||||
// 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
|
||||
@@ -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),
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -39,6 +48,7 @@ func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
|
||||
}
|
||||
return &AgentSharedState{
|
||||
ConversationTracker: conversations.NewConversationTracker[string](lastMessageDuration),
|
||||
Reminders: make([]ReminderActionResponse, 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user