recursive/tools/
schedule_wakeup.rs1use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use async_trait::async_trait;
10use serde_json::{json, Value};
11
12use super::Tool;
13use crate::error::Result;
14use crate::llm::ToolSpec;
15
16pub type WakeupSlot = Arc<Mutex<Option<WakeupRequest>>>;
18
19#[derive(Debug, Clone)]
21pub struct WakeupRequest {
22 pub delay: Duration,
23 pub reason: String,
24 pub prompt: String,
25}
26
27pub struct ScheduleWakeup {
29 slot: WakeupSlot,
30}
31
32impl ScheduleWakeup {
33 pub fn new(slot: WakeupSlot) -> Self {
34 Self { slot }
35 }
36}
37
38#[async_trait]
39impl Tool for ScheduleWakeup {
40 fn is_deferred(&self) -> bool {
41 true
42 }
43
44 fn spec(&self) -> ToolSpec {
45 ToolSpec {
46 name: "schedule_wakeup".into(),
47 description: "Schedule the next loop iteration. The runner will \
48 sleep for delay_secs then re-invoke the agent with \
49 the given prompt. Call this to keep the loop alive."
50 .into(),
51 parameters: json!({
52 "type": "object",
53 "properties": {
54 "delay_secs": {
55 "type": "integer",
56 "description": "Seconds to sleep before next turn (1-3600)",
57 "minimum": 1,
58 "maximum": 3600
59 },
60 "reason": {
61 "type": "string",
62 "description": "Why this wakeup is needed (shown in logs)"
63 },
64 "prompt": {
65 "type": "string",
66 "description": "Goal/context for the next turn"
67 }
68 },
69 "required": ["delay_secs", "reason", "prompt"]
70 }),
71 }
72 }
73
74 async fn execute(&self, args: Value) -> Result<String> {
75 let delay_secs = args["delay_secs"].as_u64().unwrap_or(60).clamp(1, 3600);
76 let reason = args["reason"].as_str().unwrap_or("continue").to_string();
77 let prompt = args["prompt"].as_str().unwrap_or("").to_string();
78
79 let request = WakeupRequest {
80 delay: Duration::from_secs(delay_secs),
81 reason: reason.clone(),
82 prompt,
83 };
84
85 if let Ok(mut slot) = self.slot.lock() {
86 *slot = Some(request);
87 }
88
89 Ok(format!("Wakeup scheduled: {reason} in {delay_secs}s"))
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[tokio::test]
98 async fn stores_wakeup_request() {
99 let slot: WakeupSlot = Arc::new(Mutex::new(None));
100 let tool = ScheduleWakeup::new(slot.clone());
101 let args = json!({"delay_secs": 30, "reason": "check status", "prompt": "check if done"});
102 let result = tool.execute(args).await.unwrap();
103 assert!(result.contains("30s"));
104 let req = slot.lock().unwrap().take().unwrap();
105 assert_eq!(req.delay, Duration::from_secs(30));
106 assert_eq!(req.prompt, "check if done");
107 assert_eq!(req.reason, "check status");
108 }
109
110 #[tokio::test]
111 async fn clamps_delay_max() {
112 let slot: WakeupSlot = Arc::new(Mutex::new(None));
113 let tool = ScheduleWakeup::new(slot.clone());
114 let args = json!({"delay_secs": 9999, "reason": "x", "prompt": "y"});
115 tool.execute(args).await.unwrap();
116 let req = slot.lock().unwrap().take().unwrap();
117 assert_eq!(req.delay, Duration::from_secs(3600));
118 }
119
120 #[tokio::test]
121 async fn clamps_delay_min() {
122 let slot: WakeupSlot = Arc::new(Mutex::new(None));
123 let tool = ScheduleWakeup::new(slot.clone());
124 let args = json!({"delay_secs": 0, "reason": "x", "prompt": "y"});
125 tool.execute(args).await.unwrap();
126 let req = slot.lock().unwrap().take().unwrap();
127 assert_eq!(req.delay, Duration::from_secs(1));
128 }
129
130 #[tokio::test]
131 async fn overwrites_previous_request() {
132 let slot: WakeupSlot = Arc::new(Mutex::new(None));
133 let tool = ScheduleWakeup::new(slot.clone());
134 tool.execute(json!({"delay_secs": 10, "reason": "first", "prompt": "a"}))
135 .await
136 .unwrap();
137 tool.execute(json!({"delay_secs": 20, "reason": "second", "prompt": "b"}))
138 .await
139 .unwrap();
140 let req = slot.lock().unwrap().take().unwrap();
141 assert_eq!(req.delay, Duration::from_secs(20));
142 assert_eq!(req.prompt, "b");
143 }
144
145 #[test]
146 fn is_deferred_is_true() {
147 let slot: WakeupSlot = Arc::new(Mutex::new(None));
149 let tool = ScheduleWakeup::new(slot);
150 assert!(
151 tool.is_deferred(),
152 "schedule_wakeup must be a deferred tool"
153 );
154 }
155
156 #[tokio::test]
157 async fn result_message_contains_reason_and_delay() {
158 let slot: WakeupSlot = Arc::new(Mutex::new(None));
160 let tool = ScheduleWakeup::new(slot);
161 let result = tool
162 .execute(json!({"delay_secs": 42, "reason": "poll data", "prompt": "check"}))
163 .await
164 .unwrap();
165 assert!(result.contains("poll data"), "message must include reason");
166 assert!(
167 result.contains("42s"),
168 "message must include delay in seconds"
169 );
170 }
171
172 #[tokio::test]
173 async fn defaults_when_args_missing() {
174 let slot: WakeupSlot = Arc::new(Mutex::new(None));
176 let tool = ScheduleWakeup::new(slot.clone());
177 let result = tool.execute(json!({})).await.unwrap();
178 assert!(
180 result.contains("60s"),
181 "default delay must be 60s; got: {result}"
182 );
183 assert!(
185 result.contains("continue"),
186 "default reason must be 'continue'; got: {result}"
187 );
188 let req = slot.lock().unwrap().clone().unwrap();
190 assert_eq!(req.delay, std::time::Duration::from_secs(60));
191 assert_eq!(req.reason, "continue");
192 assert_eq!(req.prompt, "", "default prompt must be empty");
193 }
194
195 #[tokio::test]
196 async fn stores_prompt_correctly() {
197 let slot: WakeupSlot = Arc::new(Mutex::new(None));
199 let tool = ScheduleWakeup::new(slot.clone());
200 tool.execute(json!({"delay_secs": 10, "reason": "r", "prompt": "my prompt"}))
201 .await
202 .unwrap();
203 let req = slot.lock().unwrap().clone().unwrap();
204 assert_eq!(
205 req.prompt, "my prompt",
206 "prompt must be stored in WakeupRequest"
207 );
208 }
209}