Skip to main content

recursive/tools/
schedule_wakeup.rs

1//! schedule_wakeup tool — lets the agent request a timed re-invocation.
2//!
3//! The tool writes a `WakeupRequest` into a shared slot; `AgentRuntime::run_loop`
4//! reads it after each turn completes and decides whether to sleep-then-loop.
5
6use 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
16/// Shared slot where the tool writes a wakeup request.
17pub type WakeupSlot = Arc<Mutex<Option<WakeupRequest>>>;
18
19/// A wakeup request placed by the `schedule_wakeup` tool during a turn.
20#[derive(Debug, Clone)]
21pub struct WakeupRequest {
22    pub delay: Duration,
23    pub reason: String,
24    pub prompt: String,
25}
26
27/// Tool that lets the agent schedule its own next invocation.
28pub 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        // kills `replace ScheduleWakeup::is_deferred -> bool with false`
148        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        // kills function-level replacement of execute's return value
159        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        // kills `unwrap_or(60)` and `unwrap_or("continue")` removal mutations
175        let slot: WakeupSlot = Arc::new(Mutex::new(None));
176        let tool = ScheduleWakeup::new(slot.clone());
177        let result = tool.execute(json!({})).await.unwrap();
178        // default delay is 60s
179        assert!(
180            result.contains("60s"),
181            "default delay must be 60s; got: {result}"
182        );
183        // default reason is "continue"
184        assert!(
185            result.contains("continue"),
186            "default reason must be 'continue'; got: {result}"
187        );
188        // Verify the stored request has the default values
189        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        // kills mutation that drops the `prompt` field
198        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}