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]
77            .iter()
78            .filter(|b| **b)
79            .count();
80        if kinds == 0 {
81            return Err("CronCreate: provide one of `cron`, `at`, or `dynamic=true`".into());
82        }
83        if kinds > 1 {
84            return Err("CronCreate: `cron`, `at`, `dynamic` are mutually exclusive".into());
85        }
86
87        let (schedule, default_recurring) = if has_cron {
88            let expr = args.get("cron").and_then(Value::as_str).unwrap();
89            let cron = CronExpr::parse(expr).map_err(|e| format!("CronCreate: {e}"))?;
90            (Schedule::Cron(Box::new(cron)), true)
91        } else if has_at {
92            let raw = args.get("at").and_then(Value::as_str).unwrap();
93            let parsed: DateTime<Utc> = raw
94                .parse::<DateTime<Utc>>()
95                .map_err(|e| format!("CronCreate: invalid `at` timestamp: {e}"))?;
96            (Schedule::Once { at: parsed }, false)
97        } else {
98            (Schedule::Dynamic, true)
99        };
100
101        let recurring = args
102            .get("recurring")
103            .and_then(Value::as_bool)
104            .unwrap_or(default_recurring);
105
106        let mut sched = self
107            .scheduler
108            .lock()
109            .map_err(|_| "CronCreate: scheduler lock poisoned".to_string())?;
110
111        match sched.create(schedule, prompt, recurring) {
112            Ok(id) => {
113                let task = sched
114                    .list()
115                    .into_iter()
116                    .find(|t| t.id == id)
117                    .cloned()
118                    .ok_or_else(|| "CronCreate: just-created task vanished".to_string())?;
119                Ok(serde_json::to_string(&json!({
120                    "task_id": id.as_str(),
121                    "next_fire": task.next_fire,
122                    "expires_at": task.expires_at,
123                    "recurring": task.recurring,
124                }))
125                .unwrap())
126            }
127            Err(e) => Err(format!("CronCreate: {e}")),
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::agent::scheduler::Scheduler;
136    use serde_json::Value;
137
138    fn fresh_sched() -> Arc<Mutex<Scheduler>> {
139        // Ensure no host env var disables us during the test.
140        std::env::remove_var("CLAUDE_CODE_DISABLE_CRON");
141        std::env::remove_var("DEEPSEEK_LOOP_DISABLE_CRON");
142        Arc::new(Mutex::new(Scheduler::new("cron-create-test")))
143    }
144
145    #[tokio::test]
146    async fn create_cron_then_list_then_delete() {
147        let sched = fresh_sched();
148        let create = CronCreateTool::new(sched.clone());
149        let list = crate::agent::builtin_tools::CronListTool::new(sched.clone());
150        let delete = crate::agent::builtin_tools::CronDeleteTool::new(sched.clone());
151
152        let raw = create
153            .call_json(json!({
154                "cron": "*/5 * * * *",
155                "prompt": "check the deploy",
156                "recurring": true
157            }))
158            .await
159            .unwrap();
160        let resp: Value = serde_json::from_str(&raw).unwrap();
161        let task_id = resp["task_id"].as_str().unwrap().to_string();
162        assert_eq!(task_id.len(), 8);
163        assert_eq!(resp["recurring"].as_bool().unwrap(), true);
164
165        let listed: Value =
166            serde_json::from_str(&list.call_json(json!({})).await.unwrap()).unwrap();
167        assert_eq!(listed["count"].as_u64().unwrap(), 1);
168
169        let deleted: Value =
170            serde_json::from_str(&delete.call_json(json!({"task_id": task_id})).await.unwrap())
171                .unwrap();
172        assert_eq!(deleted["deleted"].as_bool().unwrap(), true);
173
174        let listed2: Value =
175            serde_json::from_str(&list.call_json(json!({})).await.unwrap()).unwrap();
176        assert_eq!(listed2["count"].as_u64().unwrap(), 0);
177    }
178
179    #[tokio::test]
180    async fn rejects_two_kinds_at_once() {
181        let sched = fresh_sched();
182        let create = CronCreateTool::new(sched);
183        let err = create
184            .call_json(json!({
185                "cron": "*/5 * * * *",
186                "dynamic": true,
187                "prompt": "x"
188            }))
189            .await
190            .unwrap_err();
191        assert!(err.contains("mutually exclusive"), "got: {err}");
192    }
193
194    #[tokio::test]
195    async fn requires_one_schedule_kind() {
196        let sched = fresh_sched();
197        let create = CronCreateTool::new(sched);
198        let err = create.call_json(json!({"prompt": "x"})).await.unwrap_err();
199        assert!(err.contains("one of"), "got: {err}");
200    }
201
202    #[tokio::test]
203    async fn at_default_recurring_is_false() {
204        let sched = fresh_sched();
205        let create = CronCreateTool::new(sched);
206        let future = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
207        let raw = create
208            .call_json(json!({
209                "at": future,
210                "prompt": "ping me later",
211            }))
212            .await
213            .unwrap();
214        let v: Value = serde_json::from_str(&raw).unwrap();
215        assert_eq!(v["recurring"].as_bool().unwrap(), false);
216    }
217
218    #[tokio::test]
219    async fn dynamic_default_recurring_is_true() {
220        let sched = fresh_sched();
221        let create = CronCreateTool::new(sched);
222        let raw = create
223            .call_json(json!({
224                "dynamic": true,
225                "prompt": "loop me",
226            }))
227            .await
228            .unwrap();
229        let v: Value = serde_json::from_str(&raw).unwrap();
230        assert_eq!(v["recurring"].as_bool().unwrap(), true);
231    }
232
233    #[tokio::test]
234    async fn recurring_override_wins() {
235        let sched = fresh_sched();
236        let create = CronCreateTool::new(sched);
237        // cron defaults to recurring=true; explicit false should win.
238        let raw = create
239            .call_json(json!({
240                "cron": "*/5 * * * *",
241                "prompt": "once-only via cron",
242                "recurring": false,
243            }))
244            .await
245            .unwrap();
246        let v: Value = serde_json::from_str(&raw).unwrap();
247        assert_eq!(v["recurring"].as_bool().unwrap(), false);
248    }
249
250    #[tokio::test]
251    async fn invalid_cron_surfaces_error() {
252        let sched = fresh_sched();
253        let create = CronCreateTool::new(sched);
254        let err = create
255            .call_json(json!({
256                "cron": "0 9 * * MON",
257                "prompt": "x"
258            }))
259            .await
260            .unwrap_err();
261        assert!(
262            err.contains("CronCreate") && err.contains("unsupported"),
263            "got: {err}"
264        );
265    }
266
267    #[tokio::test]
268    async fn invalid_at_timestamp_surfaces_error() {
269        let sched = fresh_sched();
270        let create = CronCreateTool::new(sched);
271        let err = create
272            .call_json(json!({
273                "at": "not-a-timestamp",
274                "prompt": "x"
275            }))
276            .await
277            .unwrap_err();
278        assert!(err.contains("invalid `at`"), "got: {err}");
279    }
280
281    #[tokio::test]
282    async fn missing_prompt_returns_error() {
283        let sched = fresh_sched();
284        let create = CronCreateTool::new(sched);
285        let err = create
286            .call_json(json!({"cron": "*/5 * * * *"}))
287            .await
288            .unwrap_err();
289        assert!(err.contains("missing string `prompt`"), "got: {err}");
290    }
291
292    #[tokio::test]
293    async fn capacity_exceeded_surfaces_error() {
294        std::env::remove_var("CLAUDE_CODE_DISABLE_CRON");
295        std::env::remove_var("DEEPSEEK_LOOP_DISABLE_CRON");
296        let sched = Arc::new(Mutex::new(crate::agent::scheduler::Scheduler::with_cap(
297            "cap-test", 2,
298        )));
299        let create = CronCreateTool::new(sched);
300
301        // Two creates ok.
302        for i in 0..2 {
303            create
304                .call_json(json!({
305                    "cron": "*/5 * * * *",
306                    "prompt": format!("p{i}"),
307                }))
308                .await
309                .unwrap();
310        }
311
312        // Third should fail with capacity message.
313        let err = create
314            .call_json(json!({
315                "cron": "*/5 * * * *",
316                "prompt": "overflow"
317            }))
318            .await
319            .unwrap_err();
320        assert!(
321            err.to_lowercase().contains("cap")
322                || err.to_lowercase().contains("max")
323                || err.to_lowercase().contains("limit"),
324            "got: {err}"
325        );
326    }
327}