1use 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: String,
38 #[serde(default)]
40 cron: String,
41 #[serde(default)]
42 run_at: String,
43 #[serde(default)]
45 goal: String,
46 #[serde(default = "default_priority")]
48 #[allow(dead_code)]
49 priority: u8,
50 #[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}