Skip to main content

supercode_harness/tools/
clock.rs

1//! BP-3 (catalog row "Clock / sleep tools", cc§1 `ScheduleWakeup` variant /
2//! cx§1 `clock`+`sleep` features): the two tools that let a model read the
3//! wall clock and pace itself.
4//!
5//! * [`CurrentTimeTool`] (`current_time`) — the current instant as an
6//!   ISO-8601/RFC3339 UTC timestamp plus, where the platform can tell us,
7//!   the local timezone name and UTC offset. Built on
8//!   `crate::sidecar::ms_to_rfc3339`, the same formatter every session
9//!   timestamp in this workspace is written with — one clock rendering, not
10//!   a second one.
11//! * [`SleepTool`] (`sleep`) — pause the turn for a bounded number of
12//!   seconds. Bounded by [`MAX_SLEEP_SECS`] (a model cannot park a session
13//!   indefinitely) and cancel-safe: the pause is a `tokio::time::sleep`
14//!   inside the tool's own future, so interrupting the turn drops it
15//!   immediately rather than leaving a timer running.
16//!
17//! Neither tool is a scheduler. `ScheduleWakeup`
18//! (`crate::agent`'s Claude-compat intrinsic) edits an imported manifest and
19//! owns no timer; these two report the time and pause THIS turn. Nothing
20//! here starts background work.
21
22use std::time::Duration;
23
24use async_trait::async_trait;
25use serde::Deserialize;
26use serde_json::{json, Value};
27
28use crate::error::{Error, Result};
29use crate::tools::{Tool, ToolContext};
30
31/// Registered name of the clock tool.
32pub const CURRENT_TIME: &str = "current_time";
33
34/// Registered name of the sleep tool.
35pub const SLEEP: &str = "sleep";
36
37/// The longest a single `sleep` call may pause the turn: four hours, the
38/// "pause up to hours" the catalog row describes, with a hard ceiling so a
39/// runaway loop cannot park a session forever.
40pub const MAX_SLEEP_SECS: f64 = 14_400.0;
41
42/// The local UTC offset in seconds, when the platform can report it.
43///
44/// `libc::localtime_r` is the only portable-enough source without taking a
45/// date-time dependency; it is unavailable off unix, where this returns
46/// `None` and the tool reports UTC alone rather than guessing.
47#[cfg(unix)]
48fn local_offset_seconds(unix_secs: i64) -> Option<i64> {
49    // SAFETY: `localtime_r` writes into a caller-owned `tm` and takes a
50    // pointer to a caller-owned `time_t`; both live for the whole call, and
51    // the null return (the only error signal) is checked before `tm` is
52    // read.
53    unsafe {
54        let t = unix_secs as libc::time_t;
55        let mut tm: libc::tm = std::mem::zeroed();
56        if libc::localtime_r(&t, &mut tm).is_null() {
57            return None;
58        }
59        Some(tm.tm_gmtoff as i64)
60    }
61}
62
63#[cfg(not(unix))]
64fn local_offset_seconds(_unix_secs: i64) -> Option<i64> {
65    None
66}
67
68/// Render an offset in seconds as `+HH:MM` / `-HH:MM`.
69fn format_offset(seconds: i64) -> String {
70    let sign = if seconds < 0 { '-' } else { '+' };
71    let abs = seconds.abs();
72    format!("{sign}{:02}:{:02}", abs / 3600, (abs % 3600) / 60)
73}
74
75/// The IANA timezone name, as far as the environment states one: `$TZ`
76/// first (the user's explicit answer), then the `/etc/localtime` symlink's
77/// zoneinfo suffix. `None` when neither says anything — never a guess.
78fn timezone_name() -> Option<String> {
79    if let Ok(tz) = std::env::var("TZ") {
80        let tz = tz.trim().trim_start_matches(':');
81        if !tz.is_empty() {
82            return Some(tz.to_string());
83        }
84    }
85    let link = std::fs::read_link("/etc/localtime").ok()?;
86    let text = link.to_string_lossy();
87    let zone = text.split_once("zoneinfo/").map(|(_, z)| z)?;
88    if zone.is_empty() {
89        None
90    } else {
91        Some(zone.to_string())
92    }
93}
94
95/// `current_time` — the wall clock, ISO-8601 plus timezone.
96#[derive(Debug, Default)]
97pub struct CurrentTimeTool;
98
99/// The payload `current_time` returns (also its structured-output shape).
100#[derive(Debug, serde::Serialize)]
101struct CurrentTime {
102    /// RFC3339 UTC instant, millisecond precision.
103    utc: String,
104    /// Unix epoch milliseconds.
105    unix_ms: i64,
106    /// IANA timezone name when the environment states one.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    timezone: Option<String>,
109    /// Local UTC offset as `+HH:MM`, when the platform can report it.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    utc_offset: Option<String>,
112    /// The same instant rendered in local time, when the offset is known.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    local: Option<String>,
115}
116
117#[async_trait]
118impl Tool for CurrentTimeTool {
119    fn name(&self) -> &str {
120        CURRENT_TIME
121    }
122    fn description(&self) -> &str {
123        "Return the current date and time (ISO-8601 UTC, plus the local timezone and offset \
124         where the platform reports them). Use it instead of assuming the date — a session \
125         can be resumed days after it started."
126    }
127    fn parameters(&self) -> Value {
128        json!({"type": "object", "properties": {}, "additionalProperties": false})
129    }
130    fn structured_output(&self) -> bool {
131        true
132    }
133    async fn execute(&self, _args: Value, _ctx: &ToolContext) -> Result<String> {
134        let now_ms = std::time::SystemTime::now()
135            .duration_since(std::time::UNIX_EPOCH)
136            .map(|d| d.as_millis() as i64)
137            .unwrap_or(0);
138        let offset = local_offset_seconds(now_ms.div_euclid(1000));
139        let payload = CurrentTime {
140            utc: crate::sidecar::ms_to_rfc3339(now_ms),
141            unix_ms: now_ms,
142            timezone: timezone_name(),
143            utc_offset: offset.map(format_offset),
144            local: offset.map(|o| {
145                let shifted = crate::sidecar::ms_to_rfc3339(now_ms + o * 1000);
146                // The shifted rendering is a LOCAL wall-clock reading, so
147                // its trailing `Z` (which would claim UTC) is replaced by
148                // the real offset.
149                format!("{}{}", shifted.trim_end_matches('Z'), format_offset(o))
150            }),
151        };
152        serde_json::to_string(&payload).map_err(|e| Error::tool(self.name(), e.to_string()))
153    }
154}
155
156#[derive(Debug, Deserialize)]
157struct SleepArgs {
158    seconds: f64,
159    #[serde(default)]
160    reason: Option<String>,
161}
162
163/// `sleep` — pause this turn for a bounded number of seconds.
164#[derive(Debug, Default)]
165pub struct SleepTool;
166
167#[async_trait]
168impl Tool for SleepTool {
169    fn name(&self) -> &str {
170        SLEEP
171    }
172    fn description(&self) -> &str {
173        "Pause for a number of seconds before continuing (for example, while waiting on a \
174         background job or a rate limit). The pause is bounded and is cancelled if the turn \
175         is interrupted."
176    }
177    fn parameters(&self) -> Value {
178        json!({
179            "type": "object",
180            "properties": {
181                "seconds": {
182                    "type": "number",
183                    "minimum": 0,
184                    "maximum": MAX_SLEEP_SECS,
185                    "description": "How long to pause, in seconds."
186                },
187                "reason": {
188                    "type": "string",
189                    "description": "Optional note about what is being waited for."
190                }
191            },
192            "required": ["seconds"],
193            "additionalProperties": false
194        })
195    }
196    async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
197        let a: SleepArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
198            tool: self.name().to_string(),
199            message: e.to_string(),
200        })?;
201        if !a.seconds.is_finite() || a.seconds < 0.0 {
202            return Err(Error::InvalidArguments {
203                tool: self.name().to_string(),
204                message: "seconds must be a non-negative number".to_string(),
205            });
206        }
207        if a.seconds > MAX_SLEEP_SECS {
208            return Err(Error::InvalidArguments {
209                tool: self.name().to_string(),
210                message: format!(
211                    "seconds must be at most {MAX_SLEEP_SECS} (asked for {}); sleep again if \
212                     you genuinely need longer",
213                    a.seconds
214                ),
215            });
216        }
217        tokio::time::sleep(Duration::from_secs_f64(a.seconds)).await;
218        Ok(match a.reason {
219            Some(reason) if !reason.trim().is_empty() => {
220                format!("Slept {} s ({reason}).", a.seconds)
221            }
222            _ => format!("Slept {} s.", a.seconds),
223        })
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[tokio::test]
232    async fn current_time_is_iso8601_and_parses_back() {
233        let ctx = ToolContext::new(std::env::temp_dir());
234        let out = CurrentTimeTool.execute(json!({}), &ctx).await.unwrap();
235        let v: Value = serde_json::from_str(&out).unwrap();
236        let utc = v["utc"].as_str().unwrap();
237        assert!(utc.ends_with('Z'), "{utc}");
238        let parsed = crate::sidecar::rfc3339_to_ms(utc).expect("round-trips");
239        assert_eq!(parsed, v["unix_ms"].as_i64().unwrap());
240    }
241
242    #[test]
243    fn offsets_render_with_a_sign_and_two_fields() {
244        assert_eq!(format_offset(0), "+00:00");
245        assert_eq!(format_offset(3600), "+01:00");
246        assert_eq!(format_offset(-27_000), "-07:30");
247    }
248
249    #[tokio::test]
250    async fn sleep_actually_waits_and_reports() {
251        let ctx = ToolContext::new(std::env::temp_dir());
252        let start = std::time::Instant::now();
253        let out = SleepTool
254            .execute(json!({"seconds": 0.05, "reason": "test"}), &ctx)
255            .await
256            .unwrap();
257        assert!(start.elapsed() >= Duration::from_millis(45));
258        assert!(out.contains("test"), "{out}");
259    }
260
261    #[tokio::test]
262    async fn sleep_is_bounded() {
263        let ctx = ToolContext::new(std::env::temp_dir());
264        let err = SleepTool
265            .execute(json!({"seconds": MAX_SLEEP_SECS + 1.0}), &ctx)
266            .await
267            .expect_err("over the cap must be refused");
268        assert!(err.to_string().contains("at most"), "{err}");
269        let err = SleepTool
270            .execute(json!({"seconds": -1}), &ctx)
271            .await
272            .expect_err("negative must be refused");
273        assert!(err.to_string().contains("non-negative"), "{err}");
274    }
275
276    /// Cancel-safety: dropping the tool's future must drop the timer with
277    /// it, so an interrupted turn never leaves a pause running.
278    #[tokio::test]
279    async fn sleep_is_cancelled_with_its_turn() {
280        let ctx = ToolContext::new(std::env::temp_dir());
281        let start = std::time::Instant::now();
282        let result = tokio::time::timeout(
283            Duration::from_millis(50),
284            SleepTool.execute(json!({"seconds": 30}), &ctx),
285        )
286        .await;
287        assert!(result.is_err(), "the sleep should still have been pending");
288        assert!(start.elapsed() < Duration::from_secs(5));
289    }
290}