supercode_harness/tools/
clock.rs1use 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
31pub const CURRENT_TIME: &str = "current_time";
33
34pub const SLEEP: &str = "sleep";
36
37pub const MAX_SLEEP_SECS: f64 = 14_400.0;
41
42#[cfg(unix)]
48fn local_offset_seconds(unix_secs: i64) -> Option<i64> {
49 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
68fn 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
75fn 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#[derive(Debug, Default)]
97pub struct CurrentTimeTool;
98
99#[derive(Debug, serde::Serialize)]
101struct CurrentTime {
102 utc: String,
104 unix_ms: i64,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 timezone: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 utc_offset: Option<String>,
112 #[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 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#[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 #[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}