feat: add schedule tool for creating and managing cron jobs

This commit is contained in:
envestcc 2026-02-20 18:44:59 +08:00
parent d692cc0cc6
commit 6838efbd1a
2 changed files with 330 additions and 0 deletions

View file

@ -194,6 +194,28 @@ func getConfigPath() string {
return filepath.Join(home, ".picoclaw", "config.json")
}
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service
cronService := cron.NewCronService(cronStorePath, nil)
// Create and register CronTool
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict)
agentLoop.RegisterTool(cronTool)
// Create and register ScheduleTool for agent use
scheduleTool := tools.NewScheduleTool(cronService)
agentLoop.RegisterTool(scheduleTool)
// Set the onJob handler
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
result := cronTool.ExecuteJob(context.Background(), job)
return result, nil
})
return cronService
}
func loadConfig() (*config.Config, error) {
return config.LoadConfig(getConfigPath())
}

308
pkg/tools/schedule.go Normal file
View file

@ -0,0 +1,308 @@
package tools
import (
"context"
"fmt"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/cron"
)
// ScheduleTool provides a scheduling interface for the AI agent
// to create, list, and cancel scheduled tasks.
type ScheduleTool struct {
cronService *cron.CronService
channel string
chatID string
mu sync.RWMutex
}
// NewScheduleTool creates a new ScheduleTool
func NewScheduleTool(cronService *cron.CronService) *ScheduleTool {
return &ScheduleTool{
cronService: cronService,
}
}
// Name returns the tool name
func (t *ScheduleTool) Name() string {
return "schedule"
}
// Description returns the tool description
func (t *ScheduleTool) Description() string {
return "Create, list, or cancel scheduled tasks. Supports one-time tasks (at), recurring intervals (every), and cron expressions."
}
// Parameters returns the tool parameters schema
func (t *ScheduleTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"enum": []string{"create", "list", "cancel"},
"description": "Action to perform: 'create' a new job, 'list' all jobs, or 'cancel' a job.",
},
"name": map[string]interface{}{
"type": "string",
"description": "Job name (required for create). A descriptive name for the scheduled task.",
},
"message": map[string]interface{}{
"type": "string",
"description": "Message content when job executes (required for create). This is what will be sent when the task triggers.",
},
"schedule": map[string]interface{}{
"type": "object",
"description": "Schedule configuration (required for create). Defines when and how often the task should run.",
"properties": map[string]interface{}{
"kind": map[string]interface{}{
"type": "string",
"enum": []string{"at", "every", "cron"},
"description": "Schedule type: 'at' for one-time at specific time, 'every' for recurring interval, 'cron' for cron expression.",
},
"at": map[string]interface{}{
"type": "string",
"description": "ISO datetime for 'at' kind (e.g., '2024-01-01T12:00:00'). Use format: YYYY-MM-DDTHH:MM:SS.",
},
"every_seconds": map[string]interface{}{
"type": "integer",
"description": "Interval in seconds for 'every' kind. Example: 3600 for every hour.",
},
"expr": map[string]interface{}{
"type": "string",
"description": "Cron expression for 'cron' kind (e.g., '0 9 * * *' for daily at 9am). Format: min hour day month dow.",
},
"timezone": map[string]interface{}{
"type": "string",
"description": "Timezone for the schedule (e.g., 'Asia/Shanghai', 'UTC'). If not specified, uses system timezone.",
},
},
"required": []string{"kind"},
},
"deliver": map[string]interface{}{
"type": "boolean",
"description": "Send result to user when job fires (default: true). If true, the message is delivered directly. If false, it's processed by the agent.",
},
"id": map[string]interface{}{
"type": "string",
"description": "Job ID to cancel (required for cancel action). Use the ID returned when creating a job.",
},
},
"required": []string{"action"},
}
}
// SetContext sets the current session context for job creation
func (t *ScheduleTool) SetContext(channel, chatID string) {
t.mu.Lock()
defer t.mu.Unlock()
t.channel = channel
t.chatID = chatID
}
// Execute runs the tool with the given arguments
func (t *ScheduleTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
action, ok := args["action"].(string)
if !ok {
return ErrorResult("action is required and must be one of: create, list, cancel")
}
switch action {
case "create":
return t.createJob(ctx, args)
case "list":
return t.listJobs()
case "cancel":
return t.cancelJob(args)
default:
return ErrorResult(fmt.Sprintf("invalid action: %s (must be create, list, or cancel)", action))
}
}
// createJob creates a new scheduled job
func (t *ScheduleTool) createJob(ctx context.Context, args map[string]interface{}) *ToolResult {
t.mu.RLock()
channel := t.channel
chatID := t.chatID
t.mu.RUnlock()
if channel == "" || chatID == "" {
return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.")
}
// Get required parameters
name, ok := args["name"].(string)
if !ok || name == "" {
return ErrorResult("name is required for create action")
}
message, ok := args["message"].(string)
if !ok || message == "" {
return ErrorResult("message is required for create action")
}
// Get schedule configuration
scheduleArg, ok := args["schedule"]
if !ok {
return ErrorResult("schedule is required for create action")
}
scheduleMap, ok := scheduleArg.(map[string]interface{})
if !ok {
return ErrorResult("schedule must be an object with kind property")
}
schedule, err := t.parseSchedule(scheduleMap)
if err != nil {
return ErrorResult(fmt.Sprintf("invalid schedule: %v", err))
}
// Get deliver parameter, default to true
deliver := true
if d, ok := args["deliver"].(bool); ok {
deliver = d
}
// Add job via cron service
job, err := t.cronService.AddJob(name, schedule, message, deliver, channel, chatID)
if err != nil {
return ErrorResult(fmt.Sprintf("error creating job: %v", err))
}
// Format next run time for response
var nextRunInfo string
if job.State.NextRunAtMS != nil {
nextTime := time.UnixMilli(*job.State.NextRunAtMS)
nextRunInfo = fmt.Sprintf(", next run: %s", nextTime.Format("2006-01-02 15:04:05"))
}
return SilentResult(fmt.Sprintf("Scheduled job '%s' (id: %s%s)", job.Name, job.ID, nextRunInfo))
}
// parseSchedule parses the schedule configuration into a CronSchedule
func (t *ScheduleTool) parseSchedule(scheduleMap map[string]interface{}) (cron.CronSchedule, error) {
kind, ok := scheduleMap["kind"].(string)
if !ok {
return cron.CronSchedule{}, fmt.Errorf("kind is required in schedule")
}
var schedule cron.CronSchedule
schedule.Kind = kind
// Get timezone if specified
tz, _ := scheduleMap["timezone"].(string)
schedule.TZ = tz
switch kind {
case "at":
atStr, ok := scheduleMap["at"].(string)
if !ok || atStr == "" {
return cron.CronSchedule{}, fmt.Errorf("at field is required for 'at' kind")
}
// Parse ISO datetime
atTime, err := time.Parse(time.RFC3339, atStr)
if err != nil {
// Try other common formats
atTime, err = time.Parse("2006-01-02T15:04:05", atStr)
if err != nil {
return cron.CronSchedule{}, fmt.Errorf("invalid at datetime format: use ISO format like '2024-01-01T12:00:00'")
}
}
// Apply timezone if specified
if tz != "" {
loc, err := time.LoadLocation(tz)
if err != nil {
return cron.CronSchedule{}, fmt.Errorf("invalid timezone: %v", err)
}
atTime = atTime.In(loc)
}
atMS := atTime.UnixMilli()
schedule.AtMS = &atMS
case "every":
everySeconds, ok := scheduleMap["every_seconds"].(float64)
if !ok {
return cron.CronSchedule{}, fmt.Errorf("every_seconds field is required for 'every' kind")
}
if everySeconds <= 0 {
return cron.CronSchedule{}, fmt.Errorf("every_seconds must be positive")
}
everyMS := int64(everySeconds) * 1000
schedule.EveryMS = &everyMS
case "cron":
expr, ok := scheduleMap["expr"].(string)
if !ok || expr == "" {
return cron.CronSchedule{}, fmt.Errorf("expr field is required for 'cron' kind")
}
schedule.Expr = expr
default:
return cron.CronSchedule{}, fmt.Errorf("invalid schedule kind: %s (must be at, every, or cron)", kind)
}
return schedule, nil
}
// listJobs lists all scheduled jobs
func (t *ScheduleTool) listJobs() *ToolResult {
jobs := t.cronService.ListJobs(true) // Include disabled jobs too
if len(jobs) == 0 {
return SilentResult("No scheduled jobs")
}
result := "Scheduled jobs:\n"
for _, j := range jobs {
var scheduleInfo string
switch j.Schedule.Kind {
case "every":
if j.Schedule.EveryMS != nil {
scheduleInfo = fmt.Sprintf("every %ds", *j.Schedule.EveryMS/1000)
} else {
scheduleInfo = "every (unknown interval)"
}
case "cron":
scheduleInfo = fmt.Sprintf("cron: %s", j.Schedule.Expr)
case "at":
scheduleInfo = "one-time"
default:
scheduleInfo = "unknown"
}
// Add next run time if available
var nextRun string
if j.State.NextRunAtMS != nil {
nextTime := time.UnixMilli(*j.State.NextRunAtMS)
nextRun = fmt.Sprintf(", next: %s", nextTime.Format("2006-01-02 15:04:05"))
}
// Add status
status := "enabled"
if !j.Enabled {
status = "disabled"
}
result += fmt.Sprintf("- %s [%s] (id: %s, %s%s)\n", j.Name, status, j.ID, scheduleInfo, nextRun)
}
return SilentResult(result)
}
// cancelJob cancels/removes a scheduled job
func (t *ScheduleTool) cancelJob(args map[string]interface{}) *ToolResult {
jobID, ok := args["id"].(string)
if !ok || jobID == "" {
return ErrorResult("id is required for cancel action")
}
if t.cronService.RemoveJob(jobID) {
return SilentResult(fmt.Sprintf("Cancelled job: %s", jobID))
}
return ErrorResult(fmt.Sprintf("job not found: %s", jobID))
}