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].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 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}