From 7968f3c7832daeeaf208a57eceac3e40f5f0dc91 Mon Sep 17 00:00:00 2001 From: Pranav Agarkar <90404176+PranavAgarkar07@users.noreply.github.com> Date: Sat, 16 May 2026 08:32:50 +0530 Subject: [PATCH] fix(composio): normalize bare calendar dates to RFC 3339 before dispatch (#1802) Co-authored-by: Steven Enamakel --- src/openhuman/composio/client.rs | 55 ++++++++++++++- src/openhuman/composio/client_tests.rs | 98 ++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/src/openhuman/composio/client.rs b/src/openhuman/composio/client.rs index fbe8646df..aa988cde0 100644 --- a/src/openhuman/composio/client.rs +++ b/src/openhuman/composio/client.rs @@ -197,7 +197,8 @@ impl ComposioClient { if tool.is_empty() { anyhow::bail!("composio.execute_tool: tool slug must not be empty"); } - let arguments = arguments.unwrap_or(serde_json::Value::Object(Default::default())); + let mut arguments = arguments.unwrap_or(serde_json::Value::Object(Default::default())); + normalize_calendar_query_args(tool, &mut arguments); tracing::debug!(tool = %tool, "[composio] execute_tool"); let body = json!({ "tool": tool, "arguments": arguments }); self.execute_tool_with_post_oauth_retry(tool, &body, POST_OAUTH_ACTION_RETRY_DELAY) @@ -505,6 +506,58 @@ impl ComposioClient { } } +/// Calendar query slugs whose `timeMin`/`timeMax` values should be +/// normalized to RFC 3339 timestamps. LLM-generated arguments sometimes +/// emit bare dates like `"2026-05-14"` instead of +/// `"2026-05-14T00:00:00Z"`, which Google Calendar rejects. +const CALENDAR_QUERY_SLUGS: &[&str] = &["GOOGLECALENDAR_EVENTS_LIST", "GOOGLECALENDAR_FIND_EVENT"]; + +/// Normalize `timeMin`/`timeMax` from bare dates to RFC 3339 for +/// Google Calendar query slugs. The LLM prompt instructs the model to +/// use RFC 3339 format, but some model invocations still produce bare +/// `YYYY-MM-DD` strings. +fn normalize_calendar_query_args(tool: &str, arguments: &mut serde_json::Value) { + if !CALENDAR_QUERY_SLUGS.contains(&tool) { + return; + } + let Some(map) = arguments.as_object_mut() else { + return; + }; + for key in &["timeMin", "timeMax"] { + if let Some(serde_json::Value::String(val)) = map.get(*key).cloned() { + if is_bare_date(&val) { + let normalized = format!("{}T00:00:00Z", val); + tracing::debug!( + tool = %tool, + key = %key, + normalized = %normalized, + "[composio] normalized bare date to RFC 3339 for calendar query" + ); + map.insert((*key).to_string(), serde_json::Value::String(normalized)); + } + } + } +} + +/// Returns `true` when `s` is a bare date string like `"2026-05-14"` +/// with no time component. +fn is_bare_date(s: &str) -> bool { + if s.len() != 10 { + return false; + } + let bytes = s.as_bytes(); + bytes[0].is_ascii_digit() + && bytes[1].is_ascii_digit() + && bytes[2].is_ascii_digit() + && bytes[3].is_ascii_digit() + && bytes[4] == b'-' + && bytes[5].is_ascii_digit() + && bytes[6].is_ascii_digit() + && bytes[7] == b'-' + && bytes[8].is_ascii_digit() + && bytes[9].is_ascii_digit() +} + fn is_post_oauth_auth_readiness_error(resp: &ComposioExecuteResponse) -> bool { if resp.successful { return false; diff --git a/src/openhuman/composio/client_tests.rs b/src/openhuman/composio/client_tests.rs index c6a0e181d..d0dcdf6c0 100644 --- a/src/openhuman/composio/client_tests.rs +++ b/src/openhuman/composio/client_tests.rs @@ -854,6 +854,104 @@ async fn execute_tool_sends_tool_slug_in_request_body() { "tool slug must be forwarded in request body" ); } +// ── Calendar query argument normalization ─────────────────────── + +#[test] +fn is_bare_date_rejects_non_date_strings() { + assert!(!is_bare_date("")); + assert!(!is_bare_date("2026-05")); + assert!(!is_bare_date("2026-05-14T00:00:00Z")); + assert!(!is_bare_date("2026/05/14")); + assert!(!is_bare_date("hello-world")); + assert!(!is_bare_date("2026-5-14")); + assert!(!is_bare_date("2026-05-144")); +} + +#[test] +fn is_bare_date_accepts_valid_date_strings() { + assert!(is_bare_date("2026-05-14")); + assert!(is_bare_date("2025-01-01")); + assert!(is_bare_date("1999-12-31")); + assert!(is_bare_date("0001-01-01")); +} + +#[test] +fn normalize_calendar_query_args_ignores_non_calendar_slugs() { + let mut args = serde_json::json!({ "timeMin": "2026-05-14" }); + normalize_calendar_query_args("GMAIL_SEND_EMAIL", &mut args); + assert_eq!(args["timeMin"], "2026-05-14"); +} + +#[test] +fn normalize_calendar_query_args_converts_bare_date_to_rfc3339() { + let mut args = serde_json::json!({ + "connectionId": "conn-1", + "timeMin": "2026-05-14", + "timeMax": "2026-05-15", + }); + normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args); + assert_eq!(args["timeMin"], "2026-05-14T00:00:00Z"); + assert_eq!(args["timeMax"], "2026-05-15T00:00:00Z"); + assert_eq!(args["connectionId"], "conn-1"); +} + +#[test] +fn normalize_calendar_query_args_preserves_rfc3339_timestamp() { + let mut args = serde_json::json!({ + "timeMin": "2026-05-14T00:00:00+05:30", + "timeMax": "2026-05-14T23:59:59Z", + }); + normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args); + assert_eq!(args["timeMin"], "2026-05-14T00:00:00+05:30"); + assert_eq!(args["timeMax"], "2026-05-14T23:59:59Z"); +} + +#[test] +fn normalize_calendar_query_args_handles_missing_time_fields() { + let mut args = serde_json::json!({ "connectionId": "conn-1" }); + normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args); + assert_eq!(args["connectionId"], "conn-1"); + // timeMin/timeMax should not be inserted if absent + assert!(args.get("timeMin").is_none()); +} + +#[test] +fn normalize_calendar_query_args_handles_non_object_arguments() { + let mut args = serde_json::json!("just a string"); + normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args); + assert_eq!(args, "just a string"); +} + +#[test] +fn normalize_calendar_query_args_handles_calendar_find_event_slug() { + let mut args = serde_json::json!({ "timeMin": "2026-06-01" }); + normalize_calendar_query_args("GOOGLECALENDAR_FIND_EVENT", &mut args); + assert_eq!(args["timeMin"], "2026-06-01T00:00:00Z"); +} + +#[test] +fn normalize_calendar_query_args_normalizes_one_side_when_other_absent() { + // Asymmetric: only timeMin present, timeMax missing. The normalizer + // must convert timeMin without inserting a synthetic timeMax. + let mut args = serde_json::json!({ "timeMin": "2026-05-14" }); + normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args); + assert_eq!(args["timeMin"], "2026-05-14T00:00:00Z"); + assert!(args.get("timeMax").is_none()); +} + +#[test] +fn normalize_calendar_query_args_skips_non_string_values() { + // Non-string values (numbers, bools, nulls, objects) must be left + // untouched — the normalizer only rewrites string bare-date inputs. + let mut args = serde_json::json!({ + "timeMin": 42, + "timeMax": null, + }); + normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args); + assert_eq!(args["timeMin"], 42); + assert!(args["timeMax"].is_null()); +} + // ── Factory tests (`create_composio_client`) ──────────────────────── // // Mirror the four branches the spec demands: