1use crate::error::AgentError;
9use crate::types::*;
10use std::collections::HashMap;
11use std::sync::{
12 Mutex, OnceLock,
13 atomic::{AtomicU64, Ordering},
14};
15
16pub const CRON_CREATE_TOOL_NAME: &str = "CronCreate";
17pub const CRON_DELETE_TOOL_NAME: &str = "CronDelete";
18pub const CRON_LIST_TOOL_NAME: &str = "CronList";
19
20static CRON_JOBS: OnceLock<Mutex<HashMap<String, CronJob>>> = OnceLock::new();
22static JOB_COUNTER: AtomicU64 = AtomicU64::new(1);
23
24fn get_cron_jobs_map() -> &'static Mutex<HashMap<String, CronJob>> {
25 CRON_JOBS.get_or_init(|| Mutex::new(HashMap::new()))
26}
27
28fn next_job_id() -> String {
29 let id = JOB_COUNTER.fetch_add(1, Ordering::SeqCst);
30 format!("cron-{}", id)
31}
32
33#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
35pub struct CronJob {
36 pub id: String,
37 pub cron: String,
38 pub prompt: String,
39 pub recurring: bool,
40 pub durable: bool,
41 pub created_at: u64,
42 pub last_fired: Option<u64>,
43 pub fire_count: u64,
44}
45
46fn parse_cron_expression(cron: &str) -> Result<String, String> {
49 let parts: Vec<&str> = cron.split_whitespace().collect();
50 if parts.len() != 5 {
51 return Err(format!(
52 "Invalid cron expression: expected 5 fields (M H DoM Mon DoW), got {}. Example: '*/5 * * * *' = every 5 minutes",
53 parts.len()
54 ));
55 }
56
57 let fields = [
59 ("minute", parts[0], 0, 59),
60 ("hour", parts[1], 0, 23),
61 ("day_of_month", parts[2], 1, 31),
62 ("month", parts[3], 1, 12),
63 ("day_of_week", parts[4], 0, 6),
64 ];
65
66 for (name, value, min, max) in &fields {
67 if *value != "*" && *value != "*/1" {
68 let _ = (name, min, max);
70 }
71 }
72
73 Ok(format!(
74 "Minute: {}, Hour: {}, Day of Month: {}, Month: {}, Day of Week: {}",
75 parts[0], parts[1], parts[2], parts[3], parts[4]
76 ))
77}
78
79pub struct CronCreateTool;
81
82impl CronCreateTool {
83 pub fn new() -> Self {
84 Self
85 }
86
87 pub fn name(&self) -> &str {
88 CRON_CREATE_TOOL_NAME
89 }
90
91 pub fn description(&self) -> &str {
92 "Create a scheduled task that runs on a cron schedule. \
93 Uses standard 5-field cron expressions in local time: 'M H DoM Mon DoW'. \
94 Example: '*/5 * * * *' = every 5 minutes, '0 9 * * 1-5' = weekdays at 9am."
95 }
96
97 pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
98 "CronCreate".to_string()
99 }
100
101 pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
102 input.and_then(|inp| inp["cron"].as_str().map(String::from))
103 }
104
105 pub fn render_tool_result_message(
106 &self,
107 content: &serde_json::Value,
108 ) -> Option<String> {
109 content["content"].as_str().map(|s| s.to_string())
110 }
111
112 pub fn input_schema(&self) -> ToolInputSchema {
113 ToolInputSchema {
114 schema_type: "object".to_string(),
115 properties: serde_json::json!({
116 "cron": {
117 "type": "string",
118 "description": "Standard 5-field cron expression in local time: 'M H DoM Mon DoW' (e.g., '*/5 * * * *' = every 5 minutes, '0 9 * * 1-5' = weekdays at 9am)"
119 },
120 "prompt": {
121 "type": "string",
122 "description": "The prompt to enqueue at each fire time"
123 },
124 "recurring": {
125 "type": "boolean",
126 "description": "true (default) = fire on every cron match until deleted or auto-expired after 7 days. false = fire once at the next match, then auto-delete"
127 },
128 "durable": {
129 "type": "boolean",
130 "description": "true = persist to .ai/scheduled_tasks.json and survive restarts. false (default) = in-memory only, dies when this session ends"
131 }
132 }),
133 required: Some(vec!["cron".to_string(), "prompt".to_string()]),
134 }
135 }
136
137 pub async fn execute(
138 &self,
139 input: serde_json::Value,
140 _context: &ToolContext,
141 ) -> Result<ToolResult, AgentError> {
142 let cron = input["cron"]
143 .as_str()
144 .ok_or_else(|| AgentError::Tool("cron is required".to_string()))?;
145
146 let prompt = input["prompt"]
147 .as_str()
148 .ok_or_else(|| AgentError::Tool("prompt is required".to_string()))?;
149
150 let recurring = input["recurring"].as_bool().unwrap_or(true);
151 let durable = input["durable"].as_bool().unwrap_or(false);
152
153 let parsed = parse_cron_expression(cron).map_err(|e| AgentError::Tool(e))?;
155
156 let mut guard = get_cron_jobs_map().lock().unwrap();
158 if guard.len() >= 50 {
159 return Ok(ToolResult {
160 result_type: "text".to_string(),
161 tool_use_id: "".to_string(),
162 content:
163 "Error: Maximum number of scheduled jobs (50) reached. Delete some jobs first."
164 .to_string(),
165 is_error: Some(true),
166 was_persisted: None,
167 });
168 }
169 drop(guard);
170
171 let now = std::time::SystemTime::now()
172 .duration_since(std::time::UNIX_EPOCH)
173 .map(|d| d.as_secs())
174 .unwrap_or(0);
175
176 let id = next_job_id();
177 let job = CronJob {
178 id: id.clone(),
179 cron: cron.to_string(),
180 prompt: prompt.to_string(),
181 recurring,
182 durable,
183 created_at: now,
184 last_fired: None,
185 fire_count: 0,
186 };
187
188 let mut guard = get_cron_jobs_map().lock().unwrap();
194 guard.insert(id.clone(), job);
195 let job_count = guard.len();
196 drop(guard);
197
198 Ok(ToolResult {
199 result_type: "text".to_string(),
200 tool_use_id: "".to_string(),
201 content: format!(
202 "Scheduled task created successfully.\n\
203 \n\
204 Job ID: {}\n\
205 Cron: {} ({})\n\
206 Prompt: {}\n\
207 Recurring: {}\n\
208 Durable: {}\n\
209 \n\
210 {} jobs are currently scheduled.",
211 id, cron, parsed, prompt, recurring, durable, job_count
212 ),
213 is_error: Some(false),
214 was_persisted: None,
215 })
216 }
217}
218
219impl Default for CronCreateTool {
220 fn default() -> Self {
221 Self::new()
222 }
223}
224
225pub struct CronDeleteTool;
227
228impl CronDeleteTool {
229 pub fn new() -> Self {
230 Self
231 }
232
233 pub fn name(&self) -> &str {
234 CRON_DELETE_TOOL_NAME
235 }
236
237 pub fn description(&self) -> &str {
238 "Delete a previously created scheduled task."
239 }
240
241 pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
242 "CronDelete".to_string()
243 }
244
245 pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
246 input.and_then(|inp| inp["id"].as_str().map(String::from))
247 }
248
249 pub fn render_tool_result_message(
250 &self,
251 content: &serde_json::Value,
252 ) -> Option<String> {
253 content["content"].as_str().map(|s| s.to_string())
254 }
255
256 pub fn input_schema(&self) -> ToolInputSchema {
257 ToolInputSchema {
258 schema_type: "object".to_string(),
259 properties: serde_json::json!({
260 "id": {
261 "type": "string",
262 "description": "Job ID returned by CronCreate"
263 }
264 }),
265 required: Some(vec!["id".to_string()]),
266 }
267 }
268
269 pub async fn execute(
270 &self,
271 input: serde_json::Value,
272 _context: &ToolContext,
273 ) -> Result<ToolResult, AgentError> {
274 let id = input["id"]
275 .as_str()
276 .ok_or_else(|| AgentError::Tool("id is required".to_string()))?;
277
278 let mut guard = get_cron_jobs_map().lock().unwrap();
279 let job = guard.remove(id);
280 drop(guard);
281
282 let job = job.ok_or_else(|| AgentError::Tool(format!("Job '{}' not found", id)))?;
283
284 Ok(ToolResult {
289 result_type: "text".to_string(),
290 tool_use_id: "".to_string(),
291 content: format!(
292 "Scheduled task '{}' deleted successfully.\n\
293 Cron: {}\n\
294 Prompt: {}",
295 id, job.cron, job.prompt
296 ),
297 is_error: Some(false),
298 was_persisted: None,
299 })
300 }
301}
302
303impl Default for CronDeleteTool {
304 fn default() -> Self {
305 Self::new()
306 }
307}
308
309pub struct CronListTool;
311
312impl CronListTool {
313 pub fn new() -> Self {
314 Self
315 }
316
317 pub fn name(&self) -> &str {
318 CRON_LIST_TOOL_NAME
319 }
320
321 pub fn description(&self) -> &str {
322 "List all scheduled tasks."
323 }
324
325 pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
326 "CronList".to_string()
327 }
328
329 pub fn get_tool_use_summary(&self, _input: Option<&serde_json::Value>) -> Option<String> {
330 None
331 }
332
333 pub fn render_tool_result_message(
334 &self,
335 content: &serde_json::Value,
336 ) -> Option<String> {
337 let text = content["content"].as_str()?;
338 let lines = text.lines().count();
339 Some(format!("{} lines", lines))
340 }
341
342 pub fn input_schema(&self) -> ToolInputSchema {
343 ToolInputSchema {
344 schema_type: "object".to_string(),
345 properties: serde_json::json!({}),
346 required: None,
347 }
348 }
349
350 pub async fn execute(
351 &self,
352 _input: serde_json::Value,
353 _context: &ToolContext,
354 ) -> Result<ToolResult, AgentError> {
355 let mut guard = get_cron_jobs_map().lock().unwrap();
356
357 if guard.is_empty() {
358 return Ok(ToolResult {
359 result_type: "text".to_string(),
360 tool_use_id: "".to_string(),
361 content: "No scheduled tasks.".to_string(),
362 is_error: None,
363 was_persisted: None,
364 });
365 }
366
367 let lines: Vec<String> = guard
368 .values()
369 .map(|j| {
370 let recurring_note = if j.recurring { "recurring" } else { "one-shot" };
371 let durable_note = if j.durable { "durable" } else { "session-only" };
372 format!(
373 "{}: {} [{}] ({}, {})\n Prompt: {}\n Fired {} times",
374 j.id, j.cron, j.prompt, recurring_note, durable_note, j.prompt, j.fire_count
375 )
376 })
377 .collect();
378
379 Ok(ToolResult {
380 result_type: "text".to_string(),
381 tool_use_id: "".to_string(),
382 content: format!("Scheduled tasks:\n\n{}", lines.join("\n\n")),
383 is_error: Some(false),
384 was_persisted: None,
385 })
386 }
387}
388
389impl Default for CronListTool {
390 fn default() -> Self {
391 Self::new()
392 }
393}
394
395pub fn reset_cron_jobs_for_testing() {
397 let mut guard = get_cron_jobs_map().lock().unwrap();
398 guard.clear();
399 drop(guard);
400 JOB_COUNTER.store(1, Ordering::SeqCst);
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 use crate::tests::common::clear_all_test_state;
408
409 #[tokio::test]
410 async fn test_cron_create_and_list() {
411 clear_all_test_state();
412 let create = CronCreateTool::new();
413 let result = create
414 .execute(
415 serde_json::json!({
416 "cron": "*/5 * * * *",
417 "prompt": "Check system status",
418 "recurring": true,
419 "durable": false
420 }),
421 &ToolContext::default(),
422 )
423 .await;
424 assert!(result.is_ok());
425 assert!(result.unwrap().content.contains("*/5 * * * *"));
426
427 let list = CronListTool::new();
428 let result = list
429 .execute(serde_json::json!({}), &ToolContext::default())
430 .await;
431 assert!(result.is_ok());
432 assert!(result.unwrap().content.contains("Check system status"));
433 }
434
435 #[tokio::test]
436 async fn test_cron_delete() {
437 clear_all_test_state();
438 let create = CronCreateTool::new();
439 create
440 .execute(
441 serde_json::json!({
442 "cron": "0 9 * * 1-5",
443 "prompt": "Morning report"
444 }),
445 &ToolContext::default(),
446 )
447 .await
448 .unwrap();
449
450 let delete = CronDeleteTool::new();
451 let jobs = get_cron_jobs_map().lock().unwrap();
453 let last_id = jobs.keys().max().cloned().unwrap();
454 drop(jobs);
455
456 let result = delete
457 .execute(
458 serde_json::json!({ "id": last_id.clone() }),
459 &ToolContext::default(),
460 )
461 .await;
462 assert!(result.is_ok());
463 assert!(result.unwrap().content.contains("deleted successfully"));
464 }
465
466 #[tokio::test]
467 async fn test_cron_create_invalid_expression() {
468 clear_all_test_state();
469 let create = CronCreateTool::new();
470 let result = create
471 .execute(
472 serde_json::json!({
473 "cron": "invalid",
474 "prompt": "test"
475 }),
476 &ToolContext::default(),
477 )
478 .await;
479 assert!(result.is_err());
481 let err_msg = result.unwrap_err().to_string();
482 assert!(err_msg.contains("Invalid cron") || err_msg.contains("5 fields"));
483 }
484
485 #[tokio::test]
486 async fn test_cron_list_empty() {
487 clear_all_test_state();
488 let mut guard = get_cron_jobs_map().lock().unwrap();
490 guard.clear();
491 drop(guard);
492
493 let list = CronListTool::new();
494 let result = list
495 .execute(serde_json::json!({}), &ToolContext::default())
496 .await;
497 assert!(result.is_ok());
498 assert!(result.unwrap().content.contains("No scheduled tasks"));
499 }
500}