deepseek/agent/builtin_tools/
cron_create.rs1use 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 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 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 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 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}