Skip to content

feat: add schedule tool for creating and managing cron jobs#523

Closed
envestcc wants to merge 2 commits intosipeed:mainfrom
envestcc:feat/schedule-tool
Closed

feat: add schedule tool for creating and managing cron jobs#523
envestcc wants to merge 2 commits intosipeed:mainfrom
envestcc:feat/schedule-tool

Conversation

@envestcc
Copy link
Copy Markdown

@envestcc envestcc commented Feb 20, 2026

Summary

Add a new schedule tool that allows the AI agent to create, list, and cancel scheduled tasks through the existing cron service.

Features

  • create: Create scheduled jobs with three schedule types:
    • at - One-time task at specific datetime (e.g., 2024-01-01T12:00:00)
    • every - Recurring task with interval in seconds (e.g., 3600 for every hour)
    • cron - Cron expression for complex schedules (e.g., 0 9 * * * for daily at 9am)
  • list: List all scheduled jobs with status and next run time
  • cancel: Cancel/remove jobs by ID
  • Timezone support for accurate scheduling

Changes

  • pkg/tools/schedule.go (new file, ~308 lines) - ScheduleTool implementation
  • cmd/picoclaw/main.go - Register ScheduleTool in setupCronTool()

Related Issue

Closes #351

Copilot AI review requested due to automatic review settings February 20, 2026 11:03
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a new agent-facing schedule tool that wraps the existing cron service so the agent can create, list, and cancel scheduled jobs (one-time, interval, or cron-expression based), and wires the tool into the Picoclaw startup.

Changes:

  • Added pkg/tools/schedule.go implementing the schedule tool (create, list, cancel) on top of CronService.
  • Registered the new tool in cmd/picoclaw/main.go alongside the existing cron tool.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
pkg/tools/schedule.go Introduces the ScheduleTool implementation including argument schema, schedule parsing, and cron service calls.
cmd/picoclaw/main.go Registers the ScheduleTool in setupCronTool() so it becomes available to the agent.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/tools/schedule.go Outdated
Comment on lines +205 to +220
// 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)
}
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

Timezone handling for kind: "at" is incorrect for datetimes without an explicit offset. time.Parse("2006-01-02T15:04:05", atStr) produces a UTC time, and then atTime.In(loc) converts the instant (shifting the wall clock time) instead of interpreting the input as local time in timezone. Use time.ParseInLocation (with loc when provided, otherwise time.Local) for layouts that don’t include a zone, and avoid converting the parsed instant after the fact.

Suggested change
// 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)
}
// Determine location to use for parsing times without explicit offset
var loc *time.Location
if tz != "" {
var err error
loc, err = time.LoadLocation(tz)
if err != nil {
return cron.CronSchedule{}, fmt.Errorf("invalid timezone: %v", err)
}
} else {
loc = time.Local
}
// Parse ISO datetime
atTime, err := time.Parse(time.RFC3339, atStr)
usedNaiveLayout := false
if err != nil {
// Try other common formats (without timezone, interpret in loc)
atTime, err = time.ParseInLocation("2006-01-02T15:04:05", atStr, loc)
if err != nil {
return cron.CronSchedule{}, fmt.Errorf("invalid at datetime format: use ISO format like '2024-01-01T12:00:00'")
}
usedNaiveLayout = true
}
// Apply timezone if specified for offset-aware inputs
if tz != "" && !usedNaiveLayout {

Copilot uses AI. Check for mistakes.
Comment thread pkg/tools/schedule.go
Comment on lines +194 to +197
// Get timezone if specified
tz, _ := scheduleMap["timezone"].(string)
schedule.TZ = tz

Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The tool accepts a schedule.timezone value and stores it in schedule.TZ, but CronService.computeNextRun currently ignores CronSchedule.TZ entirely. As a result, cron schedules will not actually run in the requested timezone (and every doesn’t need a timezone). Either implement TZ support in the cron service’s next-run computation or remove/clarify the timezone parameter so the tool doesn’t advertise behavior it can’t deliver.

Copilot uses AI. Check for mistakes.
Comment thread pkg/tools/schedule.go
Comment on lines +228 to +236
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
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

every_seconds parsing is too strict and can silently truncate. The schema declares it as an integer, but the implementation only accepts float64 and then casts to int64 (dropping any fractional part). Consider accepting common numeric types (e.g., int, int64, float64) and explicitly rejecting non-integer values to avoid surprising schedules.

Copilot uses AI. Check for mistakes.
Comment thread pkg/tools/schedule.go
Comment on lines +184 to +190
// 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")
}

Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

No unit tests are added for the new ScheduleTool. Since it includes non-trivial argument parsing (schedule kinds, datetime parsing, timezone validation) and user-facing formatting, consider adding tests for create/list/cancel and schedule parsing edge cases (invalid kind/expr, negative intervals, timezone errors, naive vs RFC3339 timestamps).

Copilot uses AI. Check for mistakes.
@envestcc envestcc closed this Feb 20, 2026
@envestcc envestcc reopened this Feb 20, 2026
@envestcc envestcc marked this pull request as draft February 20, 2026 11:12
Copy link
Copy Markdown

@nikolasdehor nikolasdehor left a comment

Choose a reason for hiding this comment

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

Good feature addition with thorough test coverage (585 lines of tests for 322 lines of code). A few observations:

  1. Cron expression validation — The cron kind accepts any string in expr without validating it's a legal cron expression. If the underlying CronService.AddJob doesn't validate either, a malformed expression like * * * * (4 fields) would be silently accepted and then fail at runtime. Consider adding a parse check here or documenting that validation happens downstream.

  2. No minimum interval guard for everyevery_seconds: 1 is accepted. A user (or an LLM) could create a job that fires every second, which could be abused for DoS-like behavior. Consider a minimum threshold (e.g., 30s or 60s).

  3. No "at" past-time check — Scheduling a one-time job for a time in the past will silently create a job that presumably fires immediately. Not necessarily a bug, but it might be worth either rejecting past times or documenting the behavior.

  4. Context race conditionSetContext writes channel/chatID with a write lock, and createJob reads with a read lock. This is correct for mutex safety, but semantically there's a TOCTOU issue: if two concurrent tool invocations from different chats call SetContext and then createJob, the second SetContext could overwrite the first before the first createJob reads. Consider passing channel/chatID via the args instead of mutable state, or accepting this as a known limitation for single-user deployments.

  5. listJobs string concatenation — Minor: this function still uses += string concatenation in a loop. Given PR #524 is converting these, you might want to use strings.Builder here for consistency.

  6. setupCronTool in main.go — Missing blank line before func loadConfig(). Cosmetic.

Overall well-structured. Tests cover all error paths and happy paths. LGTM with the above as optional improvements.

@envestcc envestcc marked this pull request as ready for review February 23, 2026 07:13
Copilot AI review requested due to automatic review settings February 23, 2026 07:13
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cmd/picoclaw/main.go
Comment on lines +197 to +218
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
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

This new setupCronTool() in main.go will not compile: (1) the package already defines setupCronTool in cmd_gateway.go (duplicate symbol in the same package), and (2) tools.NewCronTool is called with the wrong number of args (it currently requires execTimeout and *config.Config). Also, this file is missing the required imports for agent/bus/cron/tools/context. Consider deleting this function and instead updating/reusing the existing setupCronTool in cmd_gateway.go to register ScheduleTool.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
Comment thread pkg/tools/schedule.go
Comment on lines +65 to +80
"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. Must be a positive integer.",
},
"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 interpreting 'at' times without explicit offset (e.g., 'Asia/Shanghai', 'UTC'). Only applies to 'at' kind. If not specified, uses system timezone.",
},
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

The tool schema uses schedule fields that don’t match the CronSchedule JSON tags / issue #351 examples (e.g., cron.CronSchedule expects atMs/everyMs/tz, while the tool exposes at/every_seconds/timezone). To reduce model confusion and better match the existing cron API, consider supporting aliases like schedule.atMs (int64 ms), schedule.everyMs, and schedule.tz in addition to (or instead of) the current fields.

Copilot uses AI. Check for mistakes.
Comment thread pkg/tools/schedule.go
if tz != "" && !usedNaiveLayout {
atTime = atTime.In(loc)
}

Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

For kind='at', past timestamps currently parse successfully and will create an enabled job with NextRunAtMS=nil (cron service won’t run it, and one-shot jobs won’t auto-delete). Consider validating that the parsed time is in the future (e.g., > time.Now()) and returning an error if it isn’t.

Suggested change
// Validate that the scheduled time is in the future
if !atTime.After(time.Now()) {
return cron.CronSchedule{}, fmt.Errorf("at datetime must be in the future")
}

Copilot uses AI. Check for mistakes.
Comment thread pkg/tools/schedule.go
Comment on lines +252 to +258
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

Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

For kind='cron', the expression isn’t validated at creation time. If it’s invalid, cron service computeNextRun() returns nil and the job is created enabled but will never run. Consider validating the cron expression during parseSchedule/create (e.g., ensure the next tick can be computed) and return an error on invalid expressions.

Copilot uses AI. Check for mistakes.
@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@sipeed-bot
Copy link
Copy Markdown

sipeed-bot bot commented Mar 25, 2026

@envestcc Hi! This PR has had no activity for over 2 weeks, so I'm closing it for now to keep things organized. Feel free to reopen anytime if you'd like to continue.

@sipeed-bot sipeed-bot bot closed this Mar 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: expose cron job creation to the agent via tool

4 participants