fix(composio): normalize bare calendar dates to RFC 3339 before dispatch (#1802)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Pranav Agarkar
2026-05-15 20:02:50 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 32485b6b8b
commit 7968f3c783
2 changed files with 152 additions and 1 deletions
+54 -1
View File
@@ -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;
+98
View File
@@ -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: