Skip to main content

agentzero_tools/
schedule.rs

1use crate::cron_store::CronStore;
2use agentzero_core::{Tool, ToolContext, ToolResult};
3use anyhow::{anyhow, Context};
4use async_trait::async_trait;
5use serde::Deserialize;
6use std::path::PathBuf;
7
8fn cron_data_dir(workspace_root: &str) -> PathBuf {
9    PathBuf::from(workspace_root).join(".agentzero")
10}
11
12// ---------------------------------------------------------------------------
13// Natural-language schedule parsing
14// ---------------------------------------------------------------------------
15
16/// Attempt to convert a natural-language schedule expression into a cron
17/// expression. Supports common patterns like:
18///
19/// - "every 5 minutes" → "*/5 * * * *"
20/// - "every hour" → "0 * * * *"
21/// - "daily at 9am" → "0 9 * * *"
22/// - "daily at 2:30pm" → "30 14 * * *"
23/// - "weekly on monday" → "0 0 * * 1"
24/// - "hourly" → "0 * * * *"
25/// - "every day" → "0 0 * * *"
26///
27/// Returns `None` if the expression is already a valid 5-field cron or
28/// cannot be parsed.
29pub(crate) fn parse_natural_schedule(input: &str) -> Option<String> {
30    let s = input.trim().to_ascii_lowercase();
31
32    // Already a cron expression (5 space-separated fields starting with digit or *)
33    if looks_like_cron(&s) {
34        return None;
35    }
36
37    // "every N minutes"
38    if let Some(n) = extract_every_n_minutes(&s) {
39        return Some(format!("*/{n} * * * *"));
40    }
41
42    // "every N hours"
43    if let Some(n) = extract_every_n_hours(&s) {
44        return Some(format!("0 */{n} * * *"));
45    }
46
47    // "every minute"
48    if s == "every minute" {
49        return Some("* * * * *".to_string());
50    }
51
52    // "every hour" / "hourly"
53    if s == "every hour" || s == "hourly" {
54        return Some("0 * * * *".to_string());
55    }
56
57    // "every day" / "daily" (without time → midnight)
58    if s == "every day" || s == "daily" {
59        return Some("0 0 * * *".to_string());
60    }
61
62    // "daily at HH:MM" / "daily at Ham" / "daily at Hpm"
63    if let Some(cron) = parse_daily_at(&s) {
64        return Some(cron);
65    }
66
67    // "weekly" / "every week"
68    if s == "weekly" || s == "every week" {
69        return Some("0 0 * * 0".to_string());
70    }
71
72    // "weekly on <day>" / "every <day>"
73    if let Some(cron) = parse_weekly_on(&s) {
74        return Some(cron);
75    }
76
77    // "monthly" / "every month"
78    if s == "monthly" || s == "every month" {
79        return Some("0 0 1 * *".to_string());
80    }
81
82    None
83}
84
85fn looks_like_cron(s: &str) -> bool {
86    let parts: Vec<&str> = s.split_whitespace().collect();
87    parts.len() == 5 && parts[0].starts_with(|c: char| c.is_ascii_digit() || c == '*')
88}
89
90fn extract_every_n_minutes(s: &str) -> Option<u32> {
91    // "every 5 minutes" / "every 10 min"
92    let s = s.strip_prefix("every ")?;
93    let rest = s
94        .strip_suffix(" minutes")
95        .or_else(|| s.strip_suffix(" min"))?;
96    let n: u32 = rest.trim().parse().ok()?;
97    if n > 0 && n <= 59 {
98        Some(n)
99    } else {
100        None
101    }
102}
103
104fn extract_every_n_hours(s: &str) -> Option<u32> {
105    // "every 2 hours" / "every 4 hrs"
106    let s = s.strip_prefix("every ")?;
107    let rest = s
108        .strip_suffix(" hours")
109        .or_else(|| s.strip_suffix(" hrs"))
110        .or_else(|| s.strip_suffix(" hour"))?;
111    let n: u32 = rest.trim().parse().ok()?;
112    if n > 0 && n <= 23 {
113        Some(n)
114    } else {
115        None
116    }
117}
118
119fn parse_daily_at(s: &str) -> Option<String> {
120    // "daily at 9am" → "0 9 * * *"
121    // "daily at 2:30pm" → "30 14 * * *"
122    // "daily at 14:30" → "30 14 * * *"
123    // "every day at 9am" → "0 9 * * *"
124    let time_str = s
125        .strip_prefix("daily at ")
126        .or_else(|| s.strip_prefix("every day at "))?;
127    let (hour, minute) = parse_time(time_str)?;
128    Some(format!("{minute} {hour} * * *"))
129}
130
131fn parse_weekly_on(s: &str) -> Option<String> {
132    // "weekly on monday" → "0 0 * * 1"
133    // "every monday" → "0 0 * * 1"
134    let day_str = s
135        .strip_prefix("weekly on ")
136        .or_else(|| s.strip_prefix("every "))?;
137    let dow = day_of_week(day_str.trim())?;
138    Some(format!("0 0 * * {dow}"))
139}
140
141fn parse_time(s: &str) -> Option<(u32, u32)> {
142    let s = s.trim();
143
144    // Try "2:30pm" / "2:30am" / "14:30"
145    if let Some(rest) = s.strip_suffix("pm") {
146        return parse_hm(rest, true);
147    }
148    if let Some(rest) = s.strip_suffix("am") {
149        return parse_hm(rest, false);
150    }
151
152    // "14:30" (24-hour)
153    if s.contains(':') {
154        let parts: Vec<&str> = s.split(':').collect();
155        if parts.len() == 2 {
156            let h: u32 = parts[0].parse().ok()?;
157            let m: u32 = parts[1].parse().ok()?;
158            if h < 24 && m < 60 {
159                return Some((h, m));
160            }
161        }
162    }
163
164    // "9am" → just an hour
165    if let Ok(h) = s.parse::<u32>() {
166        if h < 24 {
167            return Some((h, 0));
168        }
169    }
170
171    None
172}
173
174fn parse_hm(s: &str, is_pm: bool) -> Option<(u32, u32)> {
175    let s = s.trim();
176    if s.contains(':') {
177        let parts: Vec<&str> = s.split(':').collect();
178        if parts.len() == 2 {
179            let mut h: u32 = parts[0].parse().ok()?;
180            let m: u32 = parts[1].parse().ok()?;
181            if is_pm && h < 12 {
182                h += 12;
183            }
184            if !is_pm && h == 12 {
185                h = 0;
186            }
187            if h < 24 && m < 60 {
188                return Some((h, m));
189            }
190        }
191    } else {
192        let mut h: u32 = s.parse().ok()?;
193        if is_pm && h < 12 {
194            h += 12;
195        }
196        if !is_pm && h == 12 {
197            h = 0;
198        }
199        if h < 24 {
200            return Some((h, 0));
201        }
202    }
203    None
204}
205
206fn day_of_week(s: &str) -> Option<u8> {
207    match s {
208        "sunday" | "sun" => Some(0),
209        "monday" | "mon" => Some(1),
210        "tuesday" | "tue" | "tues" => Some(2),
211        "wednesday" | "wed" => Some(3),
212        "thursday" | "thu" | "thurs" => Some(4),
213        "friday" | "fri" => Some(5),
214        "saturday" | "sat" => Some(6),
215        _ => None,
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Schedule tool (unified interface)
221// ---------------------------------------------------------------------------
222
223#[derive(Debug, Deserialize)]
224struct ScheduleInput {
225    action: String,
226    #[serde(default)]
227    id: Option<String>,
228    #[serde(default)]
229    schedule: Option<String>,
230    #[serde(default)]
231    command: Option<String>,
232}
233
234/// Unified scheduling tool that wraps cron operations and supports
235/// natural-language schedule expressions. Actions:
236///
237/// - `create` — create a new scheduled task (requires `id`, `schedule`, `command`)
238/// - `list` — list all scheduled tasks
239/// - `update` — update schedule or command for a task (requires `id`)
240/// - `remove` — remove a task (requires `id`)
241/// - `pause` — disable a task (requires `id`)
242/// - `resume` — re-enable a task (requires `id`)
243/// - `parse` — parse a natural-language expression to cron (requires `schedule`)
244#[derive(Debug, Default, Clone, Copy)]
245pub struct ScheduleTool;
246
247#[async_trait]
248impl Tool for ScheduleTool {
249    fn name(&self) -> &'static str {
250        "schedule"
251    }
252
253    fn description(&self) -> &'static str {
254        "Manage scheduled tasks: create, list, update, remove, pause, resume, or parse cron expressions."
255    }
256
257    fn input_schema(&self) -> Option<serde_json::Value> {
258        Some(serde_json::json!({
259            "type": "object",
260            "properties": {
261                "action": { "type": "string", "enum": ["create", "list", "update", "remove", "pause", "resume", "parse"], "description": "The scheduling action to perform" },
262                "id": { "type": "string", "description": "Task ID (required for create/update/remove/pause/resume)" },
263                "schedule": { "type": "string", "description": "Cron expression or natural language schedule (e.g. 'every 5 minutes')" },
264                "command": { "type": "string", "description": "Command to run on schedule" }
265            },
266            "required": ["action"],
267            "additionalProperties": false
268        }))
269    }
270
271    async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
272        let req: ScheduleInput = serde_json::from_str(input)
273            .context("schedule expects JSON: {\"action\": \"create|list|update|remove|pause|resume|parse\", ...}")?;
274
275        match req.action.as_str() {
276            "create" => handle_create(ctx, &req),
277            "list" => handle_list(ctx),
278            "update" => handle_update(ctx, &req),
279            "remove" => handle_remove(ctx, &req),
280            "pause" => handle_pause(ctx, &req),
281            "resume" => handle_resume(ctx, &req),
282            "parse" => handle_parse(&req),
283            other => Err(anyhow!(
284                "unknown action `{other}`: expected create, list, update, remove, pause, resume, or parse"
285            )),
286        }
287    }
288}
289
290fn require_id(req: &ScheduleInput) -> anyhow::Result<&str> {
291    req.id
292        .as_deref()
293        .ok_or_else(|| anyhow!("missing required field `id`"))
294}
295
296fn resolve_schedule(raw: &str) -> String {
297    parse_natural_schedule(raw).unwrap_or_else(|| raw.to_string())
298}
299
300fn handle_create(ctx: &ToolContext, req: &ScheduleInput) -> anyhow::Result<ToolResult> {
301    let id = require_id(req)?;
302    let raw_schedule = req
303        .schedule
304        .as_deref()
305        .ok_or_else(|| anyhow!("missing required field `schedule`"))?;
306    let command = req
307        .command
308        .as_deref()
309        .ok_or_else(|| anyhow!("missing required field `command`"))?;
310
311    let cron_expr = resolve_schedule(raw_schedule);
312    let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
313    let task = store.add(id, &cron_expr, command)?;
314
315    let mut out = format!(
316        "created task: id={}, schedule={}, command={}, enabled={}",
317        task.id, task.schedule, task.command, task.enabled
318    );
319    if cron_expr != raw_schedule {
320        out.push_str(&format!(
321            "\n(interpreted \"{}\" as cron: {})",
322            raw_schedule, cron_expr
323        ));
324    }
325    Ok(ToolResult { output: out })
326}
327
328fn handle_list(ctx: &ToolContext) -> anyhow::Result<ToolResult> {
329    let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
330    let tasks = store.list()?;
331    if tasks.is_empty() {
332        return Ok(ToolResult {
333            output: "no scheduled tasks".to_string(),
334        });
335    }
336    let lines: Vec<String> = tasks
337        .iter()
338        .map(|t| {
339            format!(
340                "id={} schedule={} command={} enabled={}",
341                t.id, t.schedule, t.command, t.enabled
342            )
343        })
344        .collect();
345    Ok(ToolResult {
346        output: lines.join("\n"),
347    })
348}
349
350fn handle_update(ctx: &ToolContext, req: &ScheduleInput) -> anyhow::Result<ToolResult> {
351    let id = require_id(req)?;
352    let schedule = req.schedule.as_deref().map(resolve_schedule);
353    let command = req.command.as_deref();
354
355    if schedule.is_none() && command.is_none() {
356        return Err(anyhow!(
357            "at least one of `schedule` or `command` must be provided"
358        ));
359    }
360
361    let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
362    let task = store.update(id, schedule.as_deref(), command)?;
363    Ok(ToolResult {
364        output: format!(
365            "updated task: id={}, schedule={}, command={}",
366            task.id, task.schedule, task.command
367        ),
368    })
369}
370
371fn handle_remove(ctx: &ToolContext, req: &ScheduleInput) -> anyhow::Result<ToolResult> {
372    let id = require_id(req)?;
373    let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
374    store.remove(id)?;
375    Ok(ToolResult {
376        output: format!("removed task: {id}"),
377    })
378}
379
380fn handle_pause(ctx: &ToolContext, req: &ScheduleInput) -> anyhow::Result<ToolResult> {
381    let id = require_id(req)?;
382    let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
383    let task = store.pause(id)?;
384    Ok(ToolResult {
385        output: format!("paused task: id={}, enabled={}", task.id, task.enabled),
386    })
387}
388
389fn handle_resume(ctx: &ToolContext, req: &ScheduleInput) -> anyhow::Result<ToolResult> {
390    let id = require_id(req)?;
391    let store = CronStore::new(cron_data_dir(&ctx.workspace_root))?;
392    let task = store.resume(id)?;
393    Ok(ToolResult {
394        output: format!("resumed task: id={}, enabled={}", task.id, task.enabled),
395    })
396}
397
398fn handle_parse(req: &ScheduleInput) -> anyhow::Result<ToolResult> {
399    let raw = req
400        .schedule
401        .as_deref()
402        .ok_or_else(|| anyhow!("missing required field `schedule`"))?;
403    match parse_natural_schedule(raw) {
404        Some(cron) => Ok(ToolResult {
405            output: format!("\"{}\" → {}", raw, cron),
406        }),
407        None => {
408            if looks_like_cron(&raw.to_ascii_lowercase()) {
409                Ok(ToolResult {
410                    output: format!("\"{}\" is already a valid cron expression", raw),
411                })
412            } else {
413                Err(anyhow!(
414                    "could not parse \"{}\" as a schedule expression",
415                    raw
416                ))
417            }
418        }
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use std::fs;
426    use std::sync::atomic::{AtomicU64, Ordering};
427    use std::time::{SystemTime, UNIX_EPOCH};
428
429    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
430
431    fn temp_dir() -> PathBuf {
432        let nanos = SystemTime::now()
433            .duration_since(UNIX_EPOCH)
434            .expect("clock")
435            .as_nanos();
436        let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
437        let dir = std::env::temp_dir().join(format!(
438            "agentzero-schedule-{}-{nanos}-{seq}",
439            std::process::id()
440        ));
441        fs::create_dir_all(&dir).expect("temp dir should be created");
442        dir
443    }
444
445    // --- Natural language parsing tests ---
446
447    #[test]
448    fn parse_every_n_minutes() {
449        assert_eq!(
450            parse_natural_schedule("every 5 minutes"),
451            Some("*/5 * * * *".to_string())
452        );
453        assert_eq!(
454            parse_natural_schedule("every 15 min"),
455            Some("*/15 * * * *".to_string())
456        );
457    }
458
459    #[test]
460    fn parse_every_n_hours() {
461        assert_eq!(
462            parse_natural_schedule("every 2 hours"),
463            Some("0 */2 * * *".to_string())
464        );
465        assert_eq!(
466            parse_natural_schedule("every 4 hrs"),
467            Some("0 */4 * * *".to_string())
468        );
469    }
470
471    #[test]
472    fn parse_every_minute() {
473        assert_eq!(
474            parse_natural_schedule("every minute"),
475            Some("* * * * *".to_string())
476        );
477    }
478
479    #[test]
480    fn parse_hourly() {
481        assert_eq!(
482            parse_natural_schedule("hourly"),
483            Some("0 * * * *".to_string())
484        );
485        assert_eq!(
486            parse_natural_schedule("every hour"),
487            Some("0 * * * *".to_string())
488        );
489    }
490
491    #[test]
492    fn parse_daily() {
493        assert_eq!(
494            parse_natural_schedule("daily"),
495            Some("0 0 * * *".to_string())
496        );
497        assert_eq!(
498            parse_natural_schedule("every day"),
499            Some("0 0 * * *".to_string())
500        );
501    }
502
503    #[test]
504    fn parse_daily_at_time() {
505        assert_eq!(
506            parse_natural_schedule("daily at 9am"),
507            Some("0 9 * * *".to_string())
508        );
509        assert_eq!(
510            parse_natural_schedule("daily at 2:30pm"),
511            Some("30 14 * * *".to_string())
512        );
513        assert_eq!(
514            parse_natural_schedule("daily at 14:30"),
515            Some("30 14 * * *".to_string())
516        );
517        assert_eq!(
518            parse_natural_schedule("every day at 12pm"),
519            Some("0 12 * * *".to_string())
520        );
521        assert_eq!(
522            parse_natural_schedule("daily at 12am"),
523            Some("0 0 * * *".to_string())
524        );
525    }
526
527    #[test]
528    fn parse_weekly() {
529        assert_eq!(
530            parse_natural_schedule("weekly"),
531            Some("0 0 * * 0".to_string())
532        );
533        assert_eq!(
534            parse_natural_schedule("weekly on monday"),
535            Some("0 0 * * 1".to_string())
536        );
537        assert_eq!(
538            parse_natural_schedule("every friday"),
539            Some("0 0 * * 5".to_string())
540        );
541    }
542
543    #[test]
544    fn parse_monthly() {
545        assert_eq!(
546            parse_natural_schedule("monthly"),
547            Some("0 0 1 * *".to_string())
548        );
549    }
550
551    #[test]
552    fn parse_returns_none_for_existing_cron() {
553        assert_eq!(parse_natural_schedule("*/5 * * * *"), None);
554        assert_eq!(parse_natural_schedule("0 9 * * 1"), None);
555    }
556
557    #[test]
558    fn parse_returns_none_for_unrecognized() {
559        assert_eq!(parse_natural_schedule("next tuesday"), None);
560        assert_eq!(parse_natural_schedule("gibberish"), None);
561    }
562
563    // --- Tool integration tests ---
564
565    #[tokio::test]
566    async fn schedule_create_list_remove_roundtrip() {
567        let dir = temp_dir();
568        let ctx = ToolContext::new(dir.to_string_lossy().to_string());
569        let tool = ScheduleTool;
570
571        // Create with natural language
572        let result = tool
573            .execute(
574                r#"{"action": "create", "id": "backup", "schedule": "every 5 minutes", "command": "echo hello"}"#,
575                &ctx,
576            )
577            .await
578            .expect("create should succeed");
579        assert!(result.output.contains("*/5 * * * *"));
580        assert!(result.output.contains("interpreted"));
581
582        // List
583        let result = tool
584            .execute(r#"{"action": "list"}"#, &ctx)
585            .await
586            .expect("list should succeed");
587        assert!(result.output.contains("backup"));
588
589        // Remove
590        let result = tool
591            .execute(r#"{"action": "remove", "id": "backup"}"#, &ctx)
592            .await
593            .expect("remove should succeed");
594        assert!(result.output.contains("removed"));
595
596        // Verify empty
597        let result = tool
598            .execute(r#"{"action": "list"}"#, &ctx)
599            .await
600            .expect("list should succeed");
601        assert!(result.output.contains("no scheduled tasks"));
602
603        fs::remove_dir_all(dir).ok();
604    }
605
606    #[tokio::test]
607    async fn schedule_create_with_cron_expression() {
608        let dir = temp_dir();
609        let ctx = ToolContext::new(dir.to_string_lossy().to_string());
610        let tool = ScheduleTool;
611
612        let result = tool
613            .execute(
614                r#"{"action": "create", "id": "job", "schedule": "0 9 * * 1", "command": "echo"}"#,
615                &ctx,
616            )
617            .await
618            .expect("create with cron should succeed");
619        assert!(result.output.contains("0 9 * * 1"));
620        assert!(!result.output.contains("interpreted"));
621
622        fs::remove_dir_all(dir).ok();
623    }
624
625    #[tokio::test]
626    async fn schedule_pause_resume() {
627        let dir = temp_dir();
628        let ctx = ToolContext::new(dir.to_string_lossy().to_string());
629        let tool = ScheduleTool;
630
631        tool.execute(
632            r#"{"action": "create", "id": "job", "schedule": "hourly", "command": "test"}"#,
633            &ctx,
634        )
635        .await
636        .expect("create should succeed");
637
638        let result = tool
639            .execute(r#"{"action": "pause", "id": "job"}"#, &ctx)
640            .await
641            .expect("pause should succeed");
642        assert!(result.output.contains("enabled=false"));
643
644        let result = tool
645            .execute(r#"{"action": "resume", "id": "job"}"#, &ctx)
646            .await
647            .expect("resume should succeed");
648        assert!(result.output.contains("enabled=true"));
649
650        fs::remove_dir_all(dir).ok();
651    }
652
653    #[tokio::test]
654    async fn schedule_parse_action() {
655        let tool = ScheduleTool;
656        let ctx = ToolContext::new("/tmp".to_string());
657
658        let result = tool
659            .execute(
660                r#"{"action": "parse", "schedule": "every 5 minutes"}"#,
661                &ctx,
662            )
663            .await
664            .expect("parse should succeed");
665        assert!(result.output.contains("*/5 * * * *"));
666
667        let result = tool
668            .execute(r#"{"action": "parse", "schedule": "*/5 * * * *"}"#, &ctx)
669            .await
670            .expect("parse existing cron should succeed");
671        assert!(result.output.contains("already a valid cron"));
672    }
673
674    #[tokio::test]
675    async fn schedule_unknown_action_fails() {
676        let tool = ScheduleTool;
677        let ctx = ToolContext::new("/tmp".to_string());
678
679        let err = tool
680            .execute(r#"{"action": "destroy"}"#, &ctx)
681            .await
682            .expect_err("unknown action should fail");
683        assert!(err.to_string().contains("unknown action"));
684    }
685
686    #[tokio::test]
687    async fn schedule_missing_id_fails() {
688        let tool = ScheduleTool;
689        let ctx = ToolContext::new("/tmp".to_string());
690
691        let err = tool
692            .execute(r#"{"action": "remove"}"#, &ctx)
693            .await
694            .expect_err("missing id should fail");
695        assert!(err.to_string().contains("missing required field `id`"));
696    }
697}