Skip to main content

apollo/tools/
cron_tool.rs

1//! CronTool — schedule, list, and manage recurring agent tasks.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use serde::Deserialize;
7
8use super::traits::*;
9use crate::cron_scheduler::CronScheduler;
10
11pub struct CronTool {
12    scheduler: Arc<CronScheduler>,
13    channel: String,
14    chat_id: String,
15    model: String,
16}
17
18impl CronTool {
19    pub fn new(
20        scheduler: Arc<CronScheduler>,
21        channel: impl Into<String>,
22        chat_id: impl Into<String>,
23        model: impl Into<String>,
24    ) -> Self {
25        Self {
26            scheduler,
27            channel: channel.into(),
28            chat_id: chat_id.into(),
29            model: model.into(),
30        }
31    }
32}
33
34#[derive(Deserialize)]
35struct CronArgs {
36    /// Action: "schedule", "list", "enable", "disable", "delete"
37    action: String,
38    /// Cron expression (required for schedule)
39    #[serde(default)]
40    cron: String,
41    #[serde(default)]
42    run_at: String,
43    /// Goal/task description (required for schedule)
44    #[serde(default)]
45    goal: String,
46    /// Priority 1-10 (default 5)
47    #[serde(default = "default_priority")]
48    #[allow(dead_code)]
49    priority: u8,
50    /// Schedule ID (required for enable/disable/delete)
51    #[serde(default)]
52    id: String,
53}
54
55fn default_priority() -> u8 {
56    5
57}
58
59#[async_trait]
60impl Tool for CronTool {
61    fn name(&self) -> &str {
62        "cron"
63    }
64
65    fn spec(&self) -> ToolSpec {
66        ToolSpec {
67            name: "cron".to_string(),
68            description: "Schedule recurring agent tasks using cron expressions. \
69                Actions: schedule (create), list, enable, disable, delete."
70                .to_string(),
71            parameters: serde_json::json!({
72                "type": "object",
73                "properties": {
74                    "action": {
75                        "type": "string",
76                        "enum": ["schedule", "list", "enable", "disable", "delete"],
77                        "description": "Operation to perform"
78                    },
79                    "cron": {
80                        "type": "string",
81                        "description": "Cron expression (e.g. '0 9 * * MON' = every Monday at 9am)"
82                    },
83                    "run_at": {
84                        "type": "string",
85                        "description": "RFC3339 timestamp for a one-shot task"
86                    },
87                    "goal": {
88                        "type": "string",
89                        "description": "What the agent should do when this schedule fires"
90                    },
91                    "priority": {
92                        "type": "integer",
93                        "minimum": 1,
94                        "maximum": 10,
95                        "description": "Task priority 1-10 (default 5)"
96                    },
97                    "id": {
98                        "type": "string",
99                        "description": "Schedule ID (required for enable/disable/delete)"
100                    }
101                },
102                "required": ["action"]
103            }),
104        }
105    }
106
107    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
108        let args: CronArgs = serde_json::from_str(arguments)?;
109
110        match args.action.as_str() {
111            "schedule" => {
112                if args.cron.is_empty() && args.run_at.is_empty() {
113                    return Ok(ToolResult::error("cron or run_at is required"));
114                }
115                if !args.cron.is_empty() && !args.run_at.is_empty() {
116                    return Ok(ToolResult::error("provide cron or run_at, not both"));
117                }
118                if args.goal.is_empty() {
119                    return Ok(ToolResult::error("goal is required"));
120                }
121                let result = if args.run_at.is_empty() {
122                    self.scheduler
123                        .add(
124                            "agent_task",
125                            &args.cron,
126                            &args.goal,
127                            &self.channel,
128                            &self.chat_id,
129                            &self.model,
130                        )
131                        .await
132                } else {
133                    match chrono::DateTime::parse_from_rfc3339(&args.run_at) {
134                        Ok(run_at) => {
135                            self.scheduler
136                                .add_once(
137                                    "agent_task",
138                                    run_at.with_timezone(&chrono::Utc),
139                                    &args.goal,
140                                    &self.channel,
141                                    &self.chat_id,
142                                    &self.model,
143                                )
144                                .await
145                        }
146                        Err(error) => Err(anyhow::anyhow!("invalid run_at: {error}")),
147                    }
148                };
149                match result {
150                    Ok(id) => Ok(ToolResult::success(format!(
151                        "Scheduled '{}' with id={} (cron: {})",
152                        args.goal, id, args.cron
153                    ))),
154                    Err(e) => Ok(ToolResult::error(format!("Failed to schedule: {}", e))),
155                }
156            }
157
158            "list" => {
159                let jobs = self.scheduler.list().await?;
160                if jobs.is_empty() {
161                    return Ok(ToolResult::success("No schedules configured."));
162                }
163                let lines: Vec<String> = jobs
164                    .iter()
165                    .map(|j| {
166                        let id = j.id.as_ref().map(|id| id.to_string()).unwrap_or_default();
167                        format!(
168                            "- [{}] id={} cron='{}' goal='{}' enabled={}",
169                            if j.enabled { "✓" } else { "✗" },
170                            id,
171                            j.schedule,
172                            j.task,
173                            j.enabled
174                        )
175                    })
176                    .collect();
177                Ok(ToolResult::success(lines.join("\n")))
178            }
179
180            "enable" => {
181                if args.id.is_empty() {
182                    return Ok(ToolResult::error("id is required"));
183                }
184                match self.scheduler.enable(&args.id).await {
185                    Ok(true) => Ok(ToolResult::success(format!("Enabled {}", args.id))),
186                    Ok(false) => Ok(ToolResult::error("Job not found".to_string())),
187                    Err(e) => Ok(ToolResult::error(e.to_string())),
188                }
189            }
190
191            "disable" => {
192                if args.id.is_empty() {
193                    return Ok(ToolResult::error("id is required"));
194                }
195                match self.scheduler.disable(&args.id).await {
196                    Ok(true) => Ok(ToolResult::success(format!("Disabled {}", args.id))),
197                    Ok(false) => Ok(ToolResult::error("Job not found".to_string())),
198                    Err(e) => Ok(ToolResult::error(e.to_string())),
199                }
200            }
201
202            "delete" => {
203                if args.id.is_empty() {
204                    return Ok(ToolResult::error("id is required"));
205                }
206                match self.scheduler.remove(&args.id).await {
207                    Ok(true) => Ok(ToolResult::success(format!("Deleted {}", args.id))),
208                    Ok(false) => Ok(ToolResult::error("Job not found".to_string())),
209                    Err(e) => Ok(ToolResult::error(e.to_string())),
210                }
211            }
212
213            other => Ok(ToolResult::error(format!("Unknown action: {}", other))),
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::cron_scheduler::CronScheduler;
222
223    #[tokio::test]
224    async fn schedule_and_list() {
225        let scheduler = Arc::new(CronScheduler::new_noop());
226        let tool = CronTool::new(scheduler, "cli", "cli", "test-model");
227
228        let r = tool
229            .execute(r#"{"action":"schedule","cron":"0 0 9 * * * *","goal":"daily standup"}"#)
230            .await
231            .unwrap();
232        assert!(!r.is_error, "{}", r.output);
233
234        let l = tool.execute(r#"{"action":"list"}"#).await.unwrap();
235        assert!(l.output.contains("daily standup"));
236    }
237
238    #[tokio::test]
239    async fn one_shot_schedule_is_stored() {
240        let scheduler = Arc::new(CronScheduler::new_noop());
241        let tool = CronTool::new(scheduler.clone(), "cli", "cli", "test-model");
242        let run_at = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
243        let result = tool
244            .execute(&format!(
245                r#"{{"action":"schedule","run_at":"{run_at}","goal":"remind me"}}"#
246            ))
247            .await
248            .unwrap();
249        assert!(!result.is_error, "{}", result.output);
250        assert!(scheduler.list().await.unwrap()[0].one_shot);
251    }
252
253    #[tokio::test]
254    async fn invalid_cron_fails() {
255        let scheduler = Arc::new(CronScheduler::new_noop());
256        let tool = CronTool::new(scheduler, "cli", "cli", "test-model");
257        let r = tool
258            .execute(r#"{"action":"schedule","cron":"not-valid","goal":"test"}"#)
259            .await
260            .unwrap();
261        assert!(r.is_error);
262    }
263}