Skip to main content

zeph_tools/
time.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `get_current_time` tool (#6361): lets the LLM query the real current UTC time on demand.
5//!
6//! Large language models have no innate sense of wall-clock time. This executor closes that
7//! gap with a near-zero-cost local clock read (no I/O), resolved through an injectable
8//! [`zeph_common::ClockSource`] rather than calling [`std::time::SystemTime::now`] directly, so
9//! it stays deterministic in tests (spec 070 FR-002, NFR-001).
10
11use std::sync::Arc;
12
13use schemars::JsonSchema;
14use serde::Deserialize;
15use zeph_common::{ClockSource, SystemClock, ToolName};
16
17use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params};
18use crate::registry::{InvocationHint, ToolDef};
19
20const TOOL_NAME: &str = "get_current_time";
21
22const TOOL_DESCRIPTION: &str = "Returns the current UTC date and time. Use this whenever you \
23need to reason about \"today\", deadlines, or relative dates (\"next Monday\", \"in 3 days\") — \
24never assume the current date from training data. UTC only; convert to a local timezone \
25yourself if the user needs one.";
26
27/// Output format for [`GetCurrentTimeExecutor`]. UTC only (spec 070 FR-009 — timezone display
28/// is a channel concern, out of scope here).
29#[derive(Debug, PartialEq, Eq)]
30enum TimeFormat {
31    /// RFC 3339 UTC, e.g. `2026-07-18T02:30:00Z`.
32    Rfc3339,
33    /// Seconds since the Unix epoch.
34    Unix,
35}
36
37#[derive(Debug, Deserialize, JsonSchema)]
38struct TimeParams {
39    /// Output format: "rfc3339" (default) or "unix" (seconds since the Unix epoch).
40    #[serde(default)]
41    format: Option<String>,
42}
43
44/// Tool executor for the `get_current_time` tool.
45///
46/// Resolves the current time through an injectable [`ClockSource`] (default
47/// [`SystemClock`]) rather than reading the system clock directly, so callers can substitute
48/// a deterministic clock in tests.
49pub struct GetCurrentTimeExecutor {
50    clock: Arc<dyn ClockSource>,
51}
52
53impl GetCurrentTimeExecutor {
54    /// Creates an executor backed by `clock`.
55    #[must_use]
56    pub fn new(clock: Arc<dyn ClockSource>) -> Self {
57        Self { clock }
58    }
59}
60
61impl Default for GetCurrentTimeExecutor {
62    fn default() -> Self {
63        Self::new(Arc::new(SystemClock))
64    }
65}
66
67impl std::fmt::Debug for GetCurrentTimeExecutor {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("GetCurrentTimeExecutor")
70            .finish_non_exhaustive()
71    }
72}
73
74impl ToolExecutor for GetCurrentTimeExecutor {
75    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
76        if call.tool_id != TOOL_NAME {
77            return Ok(None);
78        }
79        let params: TimeParams = deserialize_params(&call.params)?;
80        let now = self.clock.now();
81        let format = match params.format.as_deref() {
82            Some("unix") => TimeFormat::Unix,
83            _ => TimeFormat::Rfc3339,
84        };
85        let summary = match format {
86            TimeFormat::Rfc3339 => zeph_common::timestamp::rfc3339_from(now),
87            TimeFormat::Unix => now
88                .duration_since(std::time::UNIX_EPOCH)
89                .map_or(0, |d| d.as_secs())
90                .to_string(),
91        };
92
93        Ok(Some(ToolOutput {
94            tool_name: ToolName::new(TOOL_NAME),
95            summary,
96            blocks_executed: 1,
97            filter_stats: None,
98            diff: None,
99            streamed: false,
100            terminal_id: None,
101            locations: None,
102            raw_response: None,
103            claim_source: None,
104            ..Default::default()
105        }))
106    }
107
108    fn tool_definitions(&self) -> Vec<ToolDef> {
109        vec![ToolDef {
110            id: TOOL_NAME.into(),
111            description: TOOL_DESCRIPTION.into(),
112            schema: schemars::schema_for!(TimeParams),
113            invocation: InvocationHint::ToolCall,
114            output_schema: None,
115            server_id: None,
116        }]
117    }
118
119    fn is_tool_retryable(&self, _tool_id: &str) -> bool {
120        true
121    }
122
123    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
124        Ok(None)
125    }
126
127    crate::tool_executor_no_inner_defaults!();
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::time::{Duration, UNIX_EPOCH};
134    use zeph_common::FixedClock;
135
136    fn make_call(format: Option<&str>) -> ToolCall {
137        let mut params = serde_json::Map::new();
138        if let Some(f) = format {
139            params.insert("format".to_owned(), serde_json::Value::String(f.to_owned()));
140        }
141        ToolCall {
142            tool_id: ToolName::new(TOOL_NAME),
143            params,
144            caller_id: None,
145            context: None,
146            tool_call_id: String::new(),
147            skill_name: None,
148        }
149    }
150
151    fn fixed_executor() -> GetCurrentTimeExecutor {
152        let t = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
153        GetCurrentTimeExecutor::new(Arc::new(FixedClock(t)))
154    }
155
156    #[tokio::test]
157    async fn returns_rfc3339_by_default() {
158        let executor = fixed_executor();
159        let result = executor
160            .execute_tool_call(&make_call(None))
161            .await
162            .unwrap()
163            .unwrap();
164        assert_eq!(result.summary, "2023-11-14T22:13:20Z");
165    }
166
167    #[tokio::test]
168    async fn returns_rfc3339_when_explicitly_requested() {
169        let executor = fixed_executor();
170        let result = executor
171            .execute_tool_call(&make_call(Some("rfc3339")))
172            .await
173            .unwrap()
174            .unwrap();
175        assert_eq!(result.summary, "2023-11-14T22:13:20Z");
176    }
177
178    #[tokio::test]
179    async fn returns_unix_seconds_when_requested() {
180        let executor = fixed_executor();
181        let result = executor
182            .execute_tool_call(&make_call(Some("unix")))
183            .await
184            .unwrap()
185            .unwrap();
186        assert_eq!(result.summary, "1700000000");
187    }
188
189    #[tokio::test]
190    async fn unrecognized_format_falls_back_to_rfc3339() {
191        // Pins the current lenient behavior explicitly, so a future change to this fallback
192        // (e.g. making it an error) is a deliberate decision, not an unnoticed regression.
193        let executor = fixed_executor();
194        let result = executor
195            .execute_tool_call(&make_call(Some("banana")))
196            .await
197            .unwrap()
198            .unwrap();
199        assert_eq!(result.summary, "2023-11-14T22:13:20Z");
200    }
201
202    #[tokio::test]
203    async fn returns_none_for_unknown_tool() {
204        let executor = fixed_executor();
205        let call = ToolCall {
206            tool_id: ToolName::new("other_tool"),
207            params: serde_json::Map::new(),
208            caller_id: None,
209            context: None,
210            tool_call_id: String::new(),
211            skill_name: None,
212        };
213        assert!(executor.execute_tool_call(&call).await.unwrap().is_none());
214    }
215
216    #[test]
217    fn tool_definitions_contains_get_current_time() {
218        let executor = GetCurrentTimeExecutor::default();
219        let defs = executor.tool_definitions();
220        assert_eq!(defs.len(), 1);
221        assert_eq!(defs[0].id.as_ref(), TOOL_NAME);
222    }
223
224    #[test]
225    fn is_retryable() {
226        let executor = GetCurrentTimeExecutor::default();
227        assert!(executor.is_tool_retryable(TOOL_NAME));
228    }
229}