use std::time::Duration;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::{Error, Result};
use crate::tools::{Tool, ToolContext};
pub const CURRENT_TIME: &str = "current_time";
pub const SLEEP: &str = "sleep";
pub const MAX_SLEEP_SECS: f64 = 14_400.0;
#[cfg(unix)]
fn local_offset_seconds(unix_secs: i64) -> Option<i64> {
unsafe {
let t = unix_secs as libc::time_t;
let mut tm: libc::tm = std::mem::zeroed();
if libc::localtime_r(&t, &mut tm).is_null() {
return None;
}
Some(tm.tm_gmtoff as i64)
}
}
#[cfg(not(unix))]
fn local_offset_seconds(_unix_secs: i64) -> Option<i64> {
None
}
fn format_offset(seconds: i64) -> String {
let sign = if seconds < 0 { '-' } else { '+' };
let abs = seconds.abs();
format!("{sign}{:02}:{:02}", abs / 3600, (abs % 3600) / 60)
}
fn timezone_name() -> Option<String> {
if let Ok(tz) = std::env::var("TZ") {
let tz = tz.trim().trim_start_matches(':');
if !tz.is_empty() {
return Some(tz.to_string());
}
}
let link = std::fs::read_link("/etc/localtime").ok()?;
let text = link.to_string_lossy();
let zone = text.split_once("zoneinfo/").map(|(_, z)| z)?;
if zone.is_empty() {
None
} else {
Some(zone.to_string())
}
}
#[derive(Debug, Default)]
pub struct CurrentTimeTool;
#[derive(Debug, serde::Serialize)]
struct CurrentTime {
utc: String,
unix_ms: i64,
#[serde(skip_serializing_if = "Option::is_none")]
timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
utc_offset: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
local: Option<String>,
}
#[async_trait]
impl Tool for CurrentTimeTool {
fn name(&self) -> &str {
CURRENT_TIME
}
fn description(&self) -> &str {
"Return the current date and time (ISO-8601 UTC, plus the local timezone and offset \
where the platform reports them). Use it instead of assuming the date — a session \
can be resumed days after it started."
}
fn parameters(&self) -> Value {
json!({"type": "object", "properties": {}, "additionalProperties": false})
}
fn structured_output(&self) -> bool {
true
}
async fn execute(&self, _args: Value, _ctx: &ToolContext) -> Result<String> {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let offset = local_offset_seconds(now_ms.div_euclid(1000));
let payload = CurrentTime {
utc: crate::sidecar::ms_to_rfc3339(now_ms),
unix_ms: now_ms,
timezone: timezone_name(),
utc_offset: offset.map(format_offset),
local: offset.map(|o| {
let shifted = crate::sidecar::ms_to_rfc3339(now_ms + o * 1000);
format!("{}{}", shifted.trim_end_matches('Z'), format_offset(o))
}),
};
serde_json::to_string(&payload).map_err(|e| Error::tool(self.name(), e.to_string()))
}
}
#[derive(Debug, Deserialize)]
struct SleepArgs {
seconds: f64,
#[serde(default)]
reason: Option<String>,
}
#[derive(Debug, Default)]
pub struct SleepTool;
#[async_trait]
impl Tool for SleepTool {
fn name(&self) -> &str {
SLEEP
}
fn description(&self) -> &str {
"Pause for a number of seconds before continuing (for example, while waiting on a \
background job or a rate limit). The pause is bounded and is cancelled if the turn \
is interrupted."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"seconds": {
"type": "number",
"minimum": 0,
"maximum": MAX_SLEEP_SECS,
"description": "How long to pause, in seconds."
},
"reason": {
"type": "string",
"description": "Optional note about what is being waited for."
}
},
"required": ["seconds"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
let a: SleepArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
tool: self.name().to_string(),
message: e.to_string(),
})?;
if !a.seconds.is_finite() || a.seconds < 0.0 {
return Err(Error::InvalidArguments {
tool: self.name().to_string(),
message: "seconds must be a non-negative number".to_string(),
});
}
if a.seconds > MAX_SLEEP_SECS {
return Err(Error::InvalidArguments {
tool: self.name().to_string(),
message: format!(
"seconds must be at most {MAX_SLEEP_SECS} (asked for {}); sleep again if \
you genuinely need longer",
a.seconds
),
});
}
tokio::time::sleep(Duration::from_secs_f64(a.seconds)).await;
Ok(match a.reason {
Some(reason) if !reason.trim().is_empty() => {
format!("Slept {} s ({reason}).", a.seconds)
}
_ => format!("Slept {} s.", a.seconds),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn current_time_is_iso8601_and_parses_back() {
let ctx = ToolContext::new(std::env::temp_dir());
let out = CurrentTimeTool.execute(json!({}), &ctx).await.unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
let utc = v["utc"].as_str().unwrap();
assert!(utc.ends_with('Z'), "{utc}");
let parsed = crate::sidecar::rfc3339_to_ms(utc).expect("round-trips");
assert_eq!(parsed, v["unix_ms"].as_i64().unwrap());
}
#[test]
fn offsets_render_with_a_sign_and_two_fields() {
assert_eq!(format_offset(0), "+00:00");
assert_eq!(format_offset(3600), "+01:00");
assert_eq!(format_offset(-27_000), "-07:30");
}
#[tokio::test]
async fn sleep_actually_waits_and_reports() {
let ctx = ToolContext::new(std::env::temp_dir());
let start = std::time::Instant::now();
let out = SleepTool
.execute(json!({"seconds": 0.05, "reason": "test"}), &ctx)
.await
.unwrap();
assert!(start.elapsed() >= Duration::from_millis(45));
assert!(out.contains("test"), "{out}");
}
#[tokio::test]
async fn sleep_is_bounded() {
let ctx = ToolContext::new(std::env::temp_dir());
let err = SleepTool
.execute(json!({"seconds": MAX_SLEEP_SECS + 1.0}), &ctx)
.await
.expect_err("over the cap must be refused");
assert!(err.to_string().contains("at most"), "{err}");
let err = SleepTool
.execute(json!({"seconds": -1}), &ctx)
.await
.expect_err("negative must be refused");
assert!(err.to_string().contains("non-negative"), "{err}");
}
#[tokio::test]
async fn sleep_is_cancelled_with_its_turn() {
let ctx = ToolContext::new(std::env::temp_dir());
let start = std::time::Instant::now();
let result = tokio::time::timeout(
Duration::from_millis(50),
SleepTool.execute(json!({"seconds": 30}), &ctx),
)
.await;
assert!(result.is_err(), "the sleep should still have been pending");
assert!(start.elapsed() < Duration::from_secs(5));
}
}