Skip to main content

deepseek/agent/builtin_tools/
cron_create.rs

1//! `CronCreate` tool — schedule a new task.
2
3use std::sync::{Arc, Mutex};
4
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use serde_json::{json, Value};
8
9use crate::agent::scheduler::{CronExpr, Schedule, Scheduler};
10use crate::agent::tool::{Tool, ToolDefinition};
11
12pub struct CronCreateTool {
13    pub scheduler: Arc<Mutex<Scheduler>>,
14}
15
16impl CronCreateTool {
17    pub fn new(scheduler: Arc<Mutex<Scheduler>>) -> Self {
18        Self { scheduler }
19    }
20}
21
22#[async_trait]
23impl Tool for CronCreateTool {
24    fn name(&self) -> &str {
25        "CronCreate"
26    }
27
28    fn definition(&self) -> ToolDefinition {
29        ToolDefinition {
30            name: self.name().to_string(),
31            description: "Schedule a prompt to run on a cron schedule, dynamic interval, \
32                          or at a one-shot time. Returns an 8-character `task_id` you can \
33                          pass to `CronDelete`. Set `recurring=false` for one-shot \
34                          (auto-deletes after firing). All times are UTC. Up to 50 tasks \
35                          per session."
36                .into(),
37            parameters: json!({
38                "type": "object",
39                "properties": {
40                    "cron": {
41                        "type": "string",
42                        "description": "5-field cron expression: \
43                                        `minute hour day-of-month month day-of-week`. \
44                                        Use this OR `at` OR `dynamic`."
45                    },
46                    "at": {
47                        "type": "string",
48                        "description": "ISO-8601 UTC timestamp for one-shot. \
49                                        Mutually exclusive with `cron`/`dynamic`."
50                    },
51                    "dynamic": {
52                        "type": "boolean",
53                        "description": "If true, the host picks the delay between iterations \
54                                        (60s..3600s). Mutually exclusive with `cron`/`at`."
55                    },
56                    "prompt": { "type": "string" },
57                    "recurring": {
58                        "type": "boolean",
59                        "description": "Default: true for cron/dynamic, false for `at`."
60                    }
61                },
62                "required": ["prompt"]
63            }),
64        }
65    }
66
67    async fn call_json(&self, args: Value) -> Result<String, String> {
68        let prompt = args
69            .get("prompt")
70            .and_then(Value::as_str)
71            .ok_or_else(|| "CronCreate: missing string `prompt`".to_string())?;
72
73        let has_cron = args.get("cron").is_some();
74        let has_at = args.get("at").is_some();
75        let has_dynamic = args.get("dynamic").and_then(Value::as_bool) == Some(true);
76        let kinds = [has_cron, has_at, has_dynamic].iter().filter(|b| **b).count();
77        if kinds == 0 {
78            return Err(
79                "CronCreate: provide one of `cron`, `at`, or `dynamic=true`".into(),
80            );
81        }
82        if kinds > 1 {
83            return Err("CronCreate: `cron`, `at`, `dynamic` are mutually exclusive".into());
84        }
85
86        let (schedule, default_recurring) = if has_cron {
87            let expr = args.get("cron").and_then(Value::as_str).unwrap();
88            let cron = CronExpr::parse(expr).map_err(|e| format!("CronCreate: {e}"))?;
89            (Schedule::Cron(Box::new(cron)), true)
90        } else if has_at {
91            let raw = args.get("at").and_then(Value::as_str).unwrap();
92            let parsed: DateTime<Utc> = raw
93                .parse::<DateTime<Utc>>()
94                .map_err(|e| format!("CronCreate: invalid `at` timestamp: {e}"))?;
95            (Schedule::Once(parsed), false)
96        } else {
97            (Schedule::Dynamic, true)
98        };
99
100        let recurring = args
101            .get("recurring")
102            .and_then(Value::as_bool)
103            .unwrap_or(default_recurring);
104
105        let mut sched = self
106            .scheduler
107            .lock()
108            .map_err(|_| "CronCreate: scheduler lock poisoned".to_string())?;
109
110        match sched.create(schedule, prompt, recurring) {
111            Ok(id) => {
112                let task = sched
113                    .list()
114                    .into_iter()
115                    .find(|t| t.id == id)
116                    .cloned()
117                    .ok_or_else(|| "CronCreate: just-created task vanished".to_string())?;
118                Ok(serde_json::to_string(&json!({
119                    "task_id": id.as_str(),
120                    "next_fire": task.next_fire,
121                    "expires_at": task.expires_at,
122                    "recurring": task.recurring,
123                }))
124                .unwrap())
125            }
126            Err(e) => Err(format!("CronCreate: {e}")),
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::agent::scheduler::Scheduler;
135    use serde_json::Value;
136
137    fn fresh_sched() -> Arc<Mutex<Scheduler>> {
138        // Ensure no host env var disables us during the test.
139        std::env::remove_var("CLAUDE_CODE_DISABLE_CRON");
140        std::env::remove_var("DEEPSEEK_LOOP_DISABLE_CRON");
141        Arc::new(Mutex::new(Scheduler::new("cron-create-test")))
142    }
143
144    #[tokio::test]
145    async fn create_cron_then_list_then_delete() {
146        let sched = fresh_sched();
147        let create = CronCreateTool::new(sched.clone());
148        let list = crate::agent::builtin_tools::CronListTool::new(sched.clone());
149        let delete = crate::agent::builtin_tools::CronDeleteTool::new(sched.clone());
150
151        let raw = create
152            .call_json(json!({
153                "cron": "*/5 * * * *",
154                "prompt": "check the deploy",
155                "recurring": true
156            }))
157            .await
158            .unwrap();
159        let resp: Value = serde_json::from_str(&raw).unwrap();
160        let task_id = resp["task_id"].as_str().unwrap().to_string();
161        assert_eq!(task_id.len(), 8);
162        assert_eq!(resp["recurring"].as_bool().unwrap(), true);
163
164        let listed: Value =
165            serde_json::from_str(&list.call_json(json!({})).await.unwrap()).unwrap();
166        assert_eq!(listed["count"].as_u64().unwrap(), 1);
167
168        let deleted: Value = serde_json::from_str(
169            &delete
170                .call_json(json!({"task_id": task_id}))
171                .await
172                .unwrap(),
173        )
174        .unwrap();
175        assert_eq!(deleted["deleted"].as_bool().unwrap(), true);
176
177        let listed2: Value =
178            serde_json::from_str(&list.call_json(json!({})).await.unwrap()).unwrap();
179        assert_eq!(listed2["count"].as_u64().unwrap(), 0);
180    }
181
182    #[tokio::test]
183    async fn rejects_two_kinds_at_once() {
184        let sched = fresh_sched();
185        let create = CronCreateTool::new(sched);
186        let err = create
187            .call_json(json!({
188                "cron": "*/5 * * * *",
189                "dynamic": true,
190                "prompt": "x"
191            }))
192            .await
193            .unwrap_err();
194        assert!(err.contains("mutually exclusive"), "got: {err}");
195    }
196
197    #[tokio::test]
198    async fn requires_one_schedule_kind() {
199        let sched = fresh_sched();
200        let create = CronCreateTool::new(sched);
201        let err = create
202            .call_json(json!({"prompt": "x"}))
203            .await
204            .unwrap_err();
205        assert!(err.contains("one of"), "got: {err}");
206    }
207}