mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
LLMs sometimes pass "" for optional parameters instead of omitting
them. Previously, passing timezone: "" or from_timezone: "" to the
time tool would trigger a parse error ("Unknown timezone ''") rather
than falling back to the context timezone or UTC.
Fix by adding .filter(|s| !s.is_empty()) after .as_str() in
resolve_timezone_for_output and optional_timezone, so empty strings
are treated the same as a missing field.
The same pattern exists in routine.rs (cron trigger timezone and
schedule fields), where "" produces "invalid IANA timezone: ''" or a
cron parse error. That will be addressed separately once routine.rs
has a test harness in place.
Regression tests added for the now and convert operations with
empty timezone strings.
Closes #1127
590 lines
18 KiB
Rust
590 lines
18 KiB
Rust
//! Time utility tool.
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, LocalResult, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
|
use chrono_tz::Tz;
|
|
|
|
use crate::context::JobContext;
|
|
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
|
|
|
/// Tool for getting current time and date operations.
|
|
pub struct TimeTool;
|
|
|
|
#[async_trait]
|
|
impl Tool for TimeTool {
|
|
fn name(&self) -> &str {
|
|
"time"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Get current time, parse or format timestamps, convert timezones, or calculate time differences."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"operation": {
|
|
"type": "string",
|
|
"enum": ["now", "parse", "convert", "format", "diff"],
|
|
"description": "The time operation to perform"
|
|
},
|
|
"input": {
|
|
"type": "string",
|
|
"description": "Input timestamp. Accepts RFC 3339, or a naive timestamp when timezone/from_timezone is provided."
|
|
},
|
|
"timestamp": {
|
|
"type": "string",
|
|
"description": "Alias for input (kept for backward compatibility)."
|
|
},
|
|
"timezone": {
|
|
"type": "string",
|
|
"description": "IANA timezone name (e.g. 'America/New_York'). Used by now/format, and can also interpret naive timestamps."
|
|
},
|
|
"from_timezone": {
|
|
"type": "string",
|
|
"description": "Source IANA timezone for naive input timestamps during convert/format/diff."
|
|
},
|
|
"to_timezone": {
|
|
"type": "string",
|
|
"description": "Target IANA timezone for convert."
|
|
},
|
|
"format": {
|
|
"type": "string",
|
|
"description": "strftime format string for format (kept for backward compatibility)."
|
|
},
|
|
"format_string": {
|
|
"type": "string",
|
|
"description": "strftime format string for format."
|
|
},
|
|
"timestamp2": {
|
|
"type": "string",
|
|
"description": "Second timestamp for diff."
|
|
}
|
|
},
|
|
"required": ["operation"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let start = std::time::Instant::now();
|
|
|
|
let operation = require_str(¶ms, "operation")?;
|
|
|
|
let result = match operation {
|
|
"now" => execute_now(¶ms, ctx)?,
|
|
"parse" => execute_parse(¶ms, ctx)?,
|
|
"convert" => execute_convert(¶ms, ctx)?,
|
|
"format" => execute_format(¶ms, ctx)?,
|
|
"diff" => execute_diff(¶ms, ctx)?,
|
|
_ => {
|
|
return Err(ToolError::InvalidParameters(format!(
|
|
"unknown operation: {}",
|
|
operation
|
|
)));
|
|
}
|
|
};
|
|
|
|
Ok(ToolOutput::success(result, start.elapsed()))
|
|
}
|
|
|
|
fn requires_sanitization(&self) -> bool {
|
|
false // Internal tool, no external data
|
|
}
|
|
}
|
|
|
|
fn execute_now(
|
|
params: &serde_json::Value,
|
|
ctx: &JobContext,
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
let now = Utc::now();
|
|
let mut result = serde_json::json!({
|
|
"iso": now.to_rfc3339(),
|
|
"utc_iso": now.to_rfc3339(),
|
|
"unix": now.timestamp(),
|
|
"unix_millis": now.timestamp_millis()
|
|
});
|
|
|
|
if let Some((tz, tz_name)) = resolve_timezone_for_output(params, ctx)? {
|
|
let local = now.with_timezone(&tz);
|
|
result["local_iso"] = serde_json::Value::String(local.to_rfc3339());
|
|
result["timezone"] = serde_json::Value::String(tz_name);
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
fn execute_parse(
|
|
params: &serde_json::Value,
|
|
ctx: &JobContext,
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
let input = require_input(params)?;
|
|
let parse_tz = resolve_parse_timezone(params, ctx)?;
|
|
let dt = parse_timestamp(input, parse_tz.as_ref())?;
|
|
|
|
Ok(serde_json::json!({
|
|
"iso": dt.to_rfc3339(),
|
|
"unix": dt.timestamp(),
|
|
"unix_millis": dt.timestamp_millis()
|
|
}))
|
|
}
|
|
|
|
fn execute_convert(
|
|
params: &serde_json::Value,
|
|
ctx: &JobContext,
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
let input = require_input(params)?;
|
|
let source_tz = optional_timezone(params, &["from_timezone", "timezone"])?;
|
|
let dt = parse_timestamp(input, source_tz.as_ref())?;
|
|
|
|
let target_name = params
|
|
.get("to_timezone")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| {
|
|
ToolError::InvalidParameters("convert operation requires 'to_timezone'".to_string())
|
|
})?;
|
|
let target_tz = parse_timezone(target_name)?;
|
|
let converted = dt.with_timezone(&target_tz);
|
|
|
|
let mut result = serde_json::json!({
|
|
"input": input,
|
|
"utc_iso": dt.to_rfc3339(),
|
|
"output": converted.to_rfc3339(),
|
|
"timezone": target_tz.to_string()
|
|
});
|
|
|
|
if let Some((ctx_tz, ctx_tz_name)) = context_timezone(ctx)? {
|
|
result["context_timezone"] = serde_json::Value::String(ctx_tz_name);
|
|
result["context_iso"] = serde_json::Value::String(dt.with_timezone(&ctx_tz).to_rfc3339());
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
fn execute_format(
|
|
params: &serde_json::Value,
|
|
ctx: &JobContext,
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
let input = require_input(params)?;
|
|
let output_tz = resolve_timezone_for_output(params, ctx)?;
|
|
let source_tz = optional_timezone(params, &["from_timezone"])?
|
|
.or_else(|| output_tz.as_ref().map(|(tz, _)| *tz));
|
|
let dt = parse_timestamp(input, source_tz.as_ref())?;
|
|
let format_string = params
|
|
.get("format_string")
|
|
.and_then(|v| v.as_str())
|
|
.or_else(|| params.get("format").and_then(|v| v.as_str()))
|
|
.unwrap_or("%Y-%m-%d %H:%M:%S %Z");
|
|
|
|
let mut result = if let Some((tz, tz_name)) = output_tz {
|
|
serde_json::json!({
|
|
"formatted": dt.with_timezone(&tz).format(format_string).to_string(),
|
|
"timezone": tz_name
|
|
})
|
|
} else {
|
|
serde_json::json!({
|
|
"formatted": dt.format(format_string).to_string()
|
|
})
|
|
};
|
|
|
|
result["utc_iso"] = serde_json::Value::String(dt.to_rfc3339());
|
|
Ok(result)
|
|
}
|
|
|
|
fn execute_diff(
|
|
params: &serde_json::Value,
|
|
ctx: &JobContext,
|
|
) -> Result<serde_json::Value, ToolError> {
|
|
let parse_tz = resolve_parse_timezone(params, ctx)?;
|
|
let ts1 = require_input(params)?;
|
|
let ts2 = params
|
|
.get("timestamp2")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| {
|
|
ToolError::InvalidParameters("diff operation requires 'timestamp2'".to_string())
|
|
})?;
|
|
|
|
let dt1 = parse_timestamp(ts1, parse_tz.as_ref())?;
|
|
let dt2 = parse_timestamp(ts2, parse_tz.as_ref())?;
|
|
let diff = dt2.signed_duration_since(dt1);
|
|
|
|
Ok(serde_json::json!({
|
|
"seconds": diff.num_seconds(),
|
|
"minutes": diff.num_minutes(),
|
|
"hours": diff.num_hours(),
|
|
"days": diff.num_days()
|
|
}))
|
|
}
|
|
|
|
fn require_input(params: &serde_json::Value) -> Result<&str, ToolError> {
|
|
params
|
|
.get("input")
|
|
.and_then(|v| v.as_str())
|
|
.or_else(|| params.get("timestamp").and_then(|v| v.as_str()))
|
|
.ok_or_else(|| {
|
|
ToolError::InvalidParameters(
|
|
"missing 'input' (or legacy 'timestamp') parameter".to_string(),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn resolve_parse_timezone(
|
|
params: &serde_json::Value,
|
|
ctx: &JobContext,
|
|
) -> Result<Option<Tz>, ToolError> {
|
|
if let Some(tz) = optional_timezone(params, &["from_timezone", "timezone"])? {
|
|
return Ok(Some(tz));
|
|
}
|
|
|
|
Ok(context_timezone(ctx)?.map(|(tz, _)| tz))
|
|
}
|
|
|
|
fn resolve_timezone_for_output(
|
|
params: &serde_json::Value,
|
|
ctx: &JobContext,
|
|
) -> Result<Option<(Tz, String)>, ToolError> {
|
|
if let Some(name) = params
|
|
.get("timezone")
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
let tz = parse_timezone(name)?;
|
|
return Ok(Some((tz, tz.to_string())));
|
|
}
|
|
|
|
context_timezone(ctx)
|
|
}
|
|
|
|
/// Resolve the user's timezone from the JobContext.
|
|
///
|
|
/// Uses `ctx.user_timezone` (set from main's timezone resolution) as the
|
|
/// primary source. Falls back to metadata fields for backward compatibility.
|
|
fn context_timezone(ctx: &JobContext) -> Result<Option<(Tz, String)>, ToolError> {
|
|
// Primary: use the dedicated user_timezone field from JobContext
|
|
if ctx.user_timezone != "UTC"
|
|
&& !ctx.user_timezone.is_empty()
|
|
&& let Some(tz) = crate::timezone::parse_timezone(&ctx.user_timezone)
|
|
{
|
|
return Ok(Some((tz, tz.to_string())));
|
|
}
|
|
|
|
// Fallback: check metadata for backward compatibility
|
|
let tz_name = ctx
|
|
.metadata
|
|
.get("user_timezone")
|
|
.and_then(|v| v.as_str())
|
|
.or_else(|| ctx.metadata.get("timezone").and_then(|v| v.as_str()));
|
|
|
|
match tz_name {
|
|
Some(name) => {
|
|
let tz = parse_timezone(name)?;
|
|
Ok(Some((tz, tz.to_string())))
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn optional_timezone(params: &serde_json::Value, keys: &[&str]) -> Result<Option<Tz>, ToolError> {
|
|
for key in keys {
|
|
if let Some(value) = params
|
|
.get(*key)
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
return parse_timezone(value).map(Some);
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn parse_timezone(value: &str) -> Result<Tz, ToolError> {
|
|
value.parse::<Tz>().map_err(|_| {
|
|
ToolError::InvalidParameters(format!(
|
|
"Unknown timezone '{}'. Use IANA names like 'America/New_York' or 'Europe/London'.",
|
|
value
|
|
))
|
|
})
|
|
}
|
|
|
|
fn parse_timestamp(input: &str, fallback_tz: Option<&Tz>) -> Result<DateTime<Utc>, ToolError> {
|
|
if let Ok(dt) = DateTime::parse_from_rfc3339(input) {
|
|
return Ok(dt.with_timezone(&Utc));
|
|
}
|
|
|
|
if let Some(naive) = parse_naive_datetime(input) {
|
|
return localize_naive_datetime(naive, fallback_tz, input);
|
|
}
|
|
|
|
Err(ToolError::InvalidParameters(format!(
|
|
"invalid timestamp '{}': expected RFC 3339 or a naive timestamp with timezone/from_timezone",
|
|
input
|
|
)))
|
|
}
|
|
|
|
fn parse_naive_datetime(input: &str) -> Option<NaiveDateTime> {
|
|
const DATETIME_FORMATS: &[&str] = &[
|
|
"%Y-%m-%d %H:%M:%S%.f",
|
|
"%Y-%m-%dT%H:%M:%S%.f",
|
|
"%Y-%m-%d %H:%M",
|
|
"%Y-%m-%dT%H:%M",
|
|
];
|
|
const DATE_FORMATS: &[&str] = &["%Y-%m-%d"];
|
|
|
|
for format in DATETIME_FORMATS {
|
|
if let Ok(value) = NaiveDateTime::parse_from_str(input, format) {
|
|
return Some(value);
|
|
}
|
|
}
|
|
|
|
for format in DATE_FORMATS {
|
|
if let Ok(date) = NaiveDate::parse_from_str(input, format) {
|
|
return date.and_hms_opt(0, 0, 0);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn localize_naive_datetime(
|
|
naive: NaiveDateTime,
|
|
fallback_tz: Option<&Tz>,
|
|
original_input: &str,
|
|
) -> Result<DateTime<Utc>, ToolError> {
|
|
let tz = fallback_tz.ok_or_else(|| {
|
|
ToolError::InvalidParameters(format!(
|
|
"timestamp '{}' has no UTC offset; provide 'timezone' or 'from_timezone'",
|
|
original_input
|
|
))
|
|
})?;
|
|
|
|
match tz.from_local_datetime(&naive) {
|
|
LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)),
|
|
LocalResult::Ambiguous(_, _) => Err(ToolError::InvalidParameters(format!(
|
|
"timestamp '{}' is ambiguous in timezone '{}'; include an explicit UTC offset instead",
|
|
original_input, tz
|
|
))),
|
|
LocalResult::None => Err(ToolError::InvalidParameters(format!(
|
|
"timestamp '{}' does not exist in timezone '{}'",
|
|
original_input, tz
|
|
))),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_now_accepts_explicit_timezone() {
|
|
let tool = TimeTool;
|
|
let ctx = JobContext::with_user("test", "chat", "test");
|
|
|
|
let output = tool
|
|
.execute(
|
|
serde_json::json!({
|
|
"operation": "now",
|
|
"timezone": "America/New_York"
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.expect("execute");
|
|
|
|
assert_eq!(output.result["timezone"].as_str(), Some("America/New_York"));
|
|
assert!(
|
|
output.result.get("utc_iso").is_some(),
|
|
"should have utc_iso"
|
|
);
|
|
assert!(
|
|
output.result.get("local_iso").is_some(),
|
|
"should have local_iso"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_now_includes_local_time_when_user_timezone_set() {
|
|
let tool = TimeTool;
|
|
let mut ctx = JobContext::with_user("test", "chat", "test");
|
|
ctx.user_timezone = "America/New_York".to_string();
|
|
|
|
let output = tool
|
|
.execute(serde_json::json!({"operation": "now"}), &ctx)
|
|
.await
|
|
.expect("execute");
|
|
assert!(
|
|
output.result.get("local_iso").is_some(),
|
|
"should have local_iso"
|
|
);
|
|
assert_eq!(
|
|
output.result["timezone"].as_str(),
|
|
Some("America/New_York"),
|
|
"should report timezone"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_now_uses_context_metadata_timezone_fallback() {
|
|
let tool = TimeTool;
|
|
let mut ctx = JobContext::with_user("test", "chat", "test");
|
|
ctx.metadata = serde_json::json!({
|
|
"user_timezone": "America/Los_Angeles"
|
|
});
|
|
|
|
let output = tool
|
|
.execute(serde_json::json!({"operation": "now"}), &ctx)
|
|
.await
|
|
.expect("execute");
|
|
|
|
assert_eq!(
|
|
output.result["timezone"].as_str(),
|
|
Some("America/Los_Angeles")
|
|
);
|
|
assert!(
|
|
output.result.get("local_iso").is_some(),
|
|
"should have local_iso"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_now_returns_utc_by_default() {
|
|
let tool = TimeTool;
|
|
let ctx = JobContext::with_user("test", "chat", "test");
|
|
// Default user_timezone is "UTC" -- context_timezone skips UTC so no
|
|
// local_iso is added, but iso and utc_iso are always present.
|
|
let output = tool
|
|
.execute(serde_json::json!({"operation": "now"}), &ctx)
|
|
.await
|
|
.expect("execute");
|
|
assert!(output.result.get("iso").is_some(), "should have iso");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_convert_across_dst_boundary() {
|
|
let tool = TimeTool;
|
|
let ctx = JobContext::with_user("test", "chat", "test");
|
|
|
|
let output = tool
|
|
.execute(
|
|
serde_json::json!({
|
|
"operation": "convert",
|
|
"input": "2026-03-08T07:30:00Z",
|
|
"to_timezone": "America/New_York"
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.expect("execute");
|
|
|
|
assert_eq!(output.result["timezone"].as_str(), Some("America/New_York"));
|
|
assert_eq!(
|
|
output.result["output"].as_str(),
|
|
Some("2026-03-08T03:30:00-04:00")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_format_with_timezone() {
|
|
let tool = TimeTool;
|
|
let ctx = JobContext::with_user("test", "chat", "test");
|
|
|
|
let output = tool
|
|
.execute(
|
|
serde_json::json!({
|
|
"operation": "format",
|
|
"input": "2026-03-08T07:30:00Z",
|
|
"timezone": "America/New_York",
|
|
"format_string": "%Y-%m-%d %H:%M:%S %Z"
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.expect("execute");
|
|
|
|
assert_eq!(output.result["timezone"].as_str(), Some("America/New_York"));
|
|
assert_eq!(
|
|
output.result["formatted"].as_str(),
|
|
Some("2026-03-08 03:30:00 EDT")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_invalid_timezone_returns_clear_error() {
|
|
let tool = TimeTool;
|
|
let ctx = JobContext::with_user("test", "chat", "test");
|
|
|
|
let err = tool
|
|
.execute(
|
|
serde_json::json!({
|
|
"operation": "now",
|
|
"timezone": "Mars/Olympus"
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.expect_err("expected invalid timezone error");
|
|
|
|
match err {
|
|
ToolError::InvalidParameters(message) => {
|
|
assert!(message.contains("Unknown timezone 'Mars/Olympus'"));
|
|
}
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_naive_timestamp_with_timezone() {
|
|
let dt = parse_timestamp("2026-03-08 03:30:00", Some(&chrono_tz::America::New_York))
|
|
.expect("parse timestamp");
|
|
|
|
assert_eq!(dt.to_rfc3339(), "2026-03-08T07:30:00+00:00");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_now_with_empty_timezone_string_does_not_error() {
|
|
// LLMs sometimes pass "" for optional fields instead of omitting them.
|
|
// Empty timezone should be treated as absent and fall back to UTC.
|
|
let tool = TimeTool;
|
|
let ctx = JobContext::with_user("test", "chat", "test");
|
|
|
|
let output = tool
|
|
.execute(
|
|
serde_json::json!({
|
|
"operation": "now",
|
|
"timezone": ""
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.expect("empty timezone string should not error");
|
|
|
|
assert!(output.result.get("iso").is_some(), "should have iso");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_convert_with_empty_from_timezone_string_does_not_error() {
|
|
// LLMs sometimes pass "" for optional fields instead of omitting them.
|
|
// Empty from_timezone should be treated as absent.
|
|
let tool = TimeTool;
|
|
let ctx = JobContext::with_user("test", "chat", "test");
|
|
|
|
let output = tool
|
|
.execute(
|
|
serde_json::json!({
|
|
"operation": "convert",
|
|
"timestamp": "2026-03-08T12:00:00Z",
|
|
"to_timezone": "America/New_York",
|
|
"from_timezone": ""
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.expect("empty from_timezone string should not error");
|
|
|
|
assert!(output.result.get("output").is_some(), "should have output");
|
|
}
|
|
}
|