supercode-harness 0.4.14

The optional native Supercode agent and tool harness
Documentation
//! BP-3 (catalog row "Clock / sleep tools", cc§1 `ScheduleWakeup` variant /
//! cx§1 `clock`+`sleep` features): the two tools that let a model read the
//! wall clock and pace itself.
//!
//! * [`CurrentTimeTool`] (`current_time`) — the current instant as an
//!   ISO-8601/RFC3339 UTC timestamp plus, where the platform can tell us,
//!   the local timezone name and UTC offset. Built on
//!   `crate::sidecar::ms_to_rfc3339`, the same formatter every session
//!   timestamp in this workspace is written with — one clock rendering, not
//!   a second one.
//! * [`SleepTool`] (`sleep`) — pause the turn for a bounded number of
//!   seconds. Bounded by [`MAX_SLEEP_SECS`] (a model cannot park a session
//!   indefinitely) and cancel-safe: the pause is a `tokio::time::sleep`
//!   inside the tool's own future, so interrupting the turn drops it
//!   immediately rather than leaving a timer running.
//!
//! Neither tool is a scheduler. `ScheduleWakeup`
//! (`crate::agent`'s Claude-compat intrinsic) edits an imported manifest and
//! owns no timer; these two report the time and pause THIS turn. Nothing
//! here starts background work.

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};

/// Registered name of the clock tool.
pub const CURRENT_TIME: &str = "current_time";

/// Registered name of the sleep tool.
pub const SLEEP: &str = "sleep";

/// The longest a single `sleep` call may pause the turn: four hours, the
/// "pause up to hours" the catalog row describes, with a hard ceiling so a
/// runaway loop cannot park a session forever.
pub const MAX_SLEEP_SECS: f64 = 14_400.0;

/// The local UTC offset in seconds, when the platform can report it.
///
/// `libc::localtime_r` is the only portable-enough source without taking a
/// date-time dependency; it is unavailable off unix, where this returns
/// `None` and the tool reports UTC alone rather than guessing.
#[cfg(unix)]
fn local_offset_seconds(unix_secs: i64) -> Option<i64> {
    // SAFETY: `localtime_r` writes into a caller-owned `tm` and takes a
    // pointer to a caller-owned `time_t`; both live for the whole call, and
    // the null return (the only error signal) is checked before `tm` is
    // read.
    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
}

/// Render an offset in seconds as `+HH:MM` / `-HH:MM`.
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)
}

/// The IANA timezone name, as far as the environment states one: `$TZ`
/// first (the user's explicit answer), then the `/etc/localtime` symlink's
/// zoneinfo suffix. `None` when neither says anything — never a guess.
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())
    }
}

/// `current_time` — the wall clock, ISO-8601 plus timezone.
#[derive(Debug, Default)]
pub struct CurrentTimeTool;

/// The payload `current_time` returns (also its structured-output shape).
#[derive(Debug, serde::Serialize)]
struct CurrentTime {
    /// RFC3339 UTC instant, millisecond precision.
    utc: String,
    /// Unix epoch milliseconds.
    unix_ms: i64,
    /// IANA timezone name when the environment states one.
    #[serde(skip_serializing_if = "Option::is_none")]
    timezone: Option<String>,
    /// Local UTC offset as `+HH:MM`, when the platform can report it.
    #[serde(skip_serializing_if = "Option::is_none")]
    utc_offset: Option<String>,
    /// The same instant rendered in local time, when the offset is known.
    #[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);
                // The shifted rendering is a LOCAL wall-clock reading, so
                // its trailing `Z` (which would claim UTC) is replaced by
                // the real offset.
                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>,
}

/// `sleep` — pause this turn for a bounded number of seconds.
#[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}");
    }

    /// Cancel-safety: dropping the tool's future must drop the timer with
    /// it, so an interrupted turn never leaves a pause running.
    #[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));
    }
}