Skip to main content

oxios_kernel/tools/builtin/
calendar_tool.rs

1//! Calendar tool — wraps `CalendarApi` behind the `AgentTool` interface.
2//!
3//! Provides agents with calendar event management capabilities.
4//! Operations: create, update, delete, list, get, search, freebusy.
5//!
6//! ## Example
7//!
8//! ```json
9//! { "op": "create", "title": "Team standup", "start": "2026-06-07T09:00:00Z", "end": "2026-06-07T09:30:00Z" }
10//! { "op": "list", "from": "2026-06-07T00:00:00Z", "to": "2026-06-14T00:00:00Z" }
11//! { "op": "search", "query": "standup" }
12//! { "op": "freebusy", "from": "2026-06-07T00:00:00Z", "to": "2026-06-14T00:00:00Z" }
13//! { "op": "delete", "uid": "event-uid-here" }
14//! ```
15
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
20use serde_json::{json, Value};
21use tokio::sync::oneshot;
22
23use crate::kernel_handle::KernelHandle;
24use oxios_calendar::{CalendarEngine, EventDraft, EventPatch, Repeat};
25
26/// Agent tool for calendar event management.
27///
28/// Wraps the [`CalendarApi`](crate::kernel_handle::CalendarApi) domain. Allows agents
29/// to create, update, delete, list, get, search events and query free-busy slots.
30///
31/// ## Operations
32///
33/// | Op         | Description               | Required params                     | Optional params                          |
34/// |------------|---------------------------|-------------------------------------|------------------------------------------|
35/// | `create`   | Create a new event        | `title`, `start`, `end`             | `all_day`, `description`, `location`, `repeat`, `reminder_minutes` |
36/// | `update`   | Update an existing event  | `uid`                               | `title`, `start`, `end`, `all_day`, `description`, `location`, `repeat`, `reminder_minutes` |
37/// | `delete`   | Delete an event           | `uid`                               | —                                        |
38/// | `list`     | List events in range      | `from`, `to`                        | —                                        |
39/// | `get`      | Get a single event        | `uid`                               | —                                        |
40/// | `search`   | Full-text search events   | `query`                             | —                                        |
41/// | `freebusy` | Free/busy slots in range  | `from`, `to`                        | —                                        |
42pub struct CalendarTool {
43    engine: Arc<CalendarEngine>,
44}
45
46impl CalendarTool {
47    /// Create a new `CalendarTool` from a `KernelHandle`.
48    ///
49    /// Returns `None` if calendar is not configured.
50    pub fn try_from_kernel(kernel: &KernelHandle) -> Option<Self> {
51        kernel.calendar.as_ref().map(|api| Self {
52            engine: api.engine.clone(),
53        })
54    }
55
56    /// Create a new `CalendarTool` from a `KernelHandle` (required).
57    ///
58    /// Panics if calendar is not configured. Use [`try_from_kernel`] for
59    /// the optional variant.
60    pub fn from_kernel(kernel: &KernelHandle) -> Self {
61        Self::try_from_kernel(kernel).expect("CalendarTool requires calendar to be configured")
62    }
63}
64
65impl std::fmt::Debug for CalendarTool {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("CalendarTool").finish()
68    }
69}
70
71/// Parse an ISO 8601 datetime string into a `chrono::DateTime<chrono::Utc>`.
72fn parse_dt(s: &str) -> Result<chrono::DateTime<chrono::Utc>, String> {
73    chrono::DateTime::parse_from_rfc3339(s)
74        .map(|dt| dt.with_timezone(&chrono::Utc))
75        .map_err(|e| {
76            format!(
77                "Invalid datetime '{s}': {e}. Use ISO 8601 / RFC 3339 format, e.g. \"2026-06-07T09:00:00Z\""
78            )
79        })
80}
81
82/// Extract an optional string parameter.
83fn opt_str<'a>(params: &'a Value, key: &str) -> Option<&'a str> {
84    params.get(key).and_then(|v| v.as_str())
85}
86
87/// Extract an optional boolean parameter.
88fn opt_bool(params: &Value, key: &str) -> Option<bool> {
89    params.get(key).and_then(|v| v.as_bool())
90}
91
92/// Extract an optional `reminder_minutes` array (Vec<u32>).
93fn opt_reminder_minutes(params: &Value) -> Option<Vec<u32>> {
94    params
95        .get("reminder_minutes")
96        .and_then(|v| v.as_array())
97        .map(|arr| {
98            arr.iter()
99                .filter_map(|v| v.as_u64().map(|n| n as u32))
100                .collect()
101        })
102}
103
104/// Extract an optional `repeat` object and parse into a [`Repeat`].
105fn opt_repeat(params: &Value) -> Option<Repeat> {
106    let obj = params.get("repeat")?.as_object()?;
107    let frequency = obj
108        .get("frequency")
109        .and_then(|v| v.as_str())
110        .unwrap_or("daily")
111        .to_string();
112    let interval = obj.get("interval").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
113    let days = obj
114        .get("days")
115        .and_then(|v| v.as_array())
116        .map(|arr| {
117            arr.iter()
118                .filter_map(|v| v.as_str().map(|s| s.to_string()))
119                .collect()
120        })
121        .unwrap_or_default();
122    let until = obj
123        .get("until")
124        .and_then(|v| v.as_str())
125        .map(|s| s.to_string());
126    let count = obj.get("count").and_then(|v| v.as_u64()).map(|v| v as u32);
127
128    Some(Repeat {
129        frequency,
130        days,
131        interval,
132        until,
133        count,
134    })
135}
136
137#[async_trait]
138impl AgentTool for CalendarTool {
139    fn name(&self) -> &str {
140        "calendar"
141    }
142
143    fn label(&self) -> &str {
144        "Calendar"
145    }
146
147    fn description(&self) -> &'static str {
148        "Manage calendar events — create, update, delete, list, search, freebusy. \
149         All datetimes use ISO 8601 / RFC 3339 format (e.g. \"2026-06-07T09:00:00Z\")."
150    }
151
152    fn parameters_schema(&self) -> Value {
153        json!({
154            "type": "object",
155            "properties": {
156                "op": {
157                    "type": "string",
158                    "enum": ["create", "update", "delete", "list", "get", "search", "freebusy"],
159                    "description": "Calendar operation to perform"
160                },
161                "title": {
162                    "type": "string",
163                    "description": "Event title (required for create, optional for update)"
164                },
165                "start": {
166                    "type": "string",
167                    "description": "Event start time (ISO 8601). Required for create, optional for update."
168                },
169                "end": {
170                    "type": "string",
171                    "description": "Event end time (ISO 8601). Required for create, optional for update."
172                },
173                "all_day": {
174                    "type": "boolean",
175                    "description": "Whether this is an all-day event"
176                },
177                "description": {
178                    "type": "string",
179                    "description": "Event description / notes"
180                },
181                "location": {
182                    "type": "string",
183                    "description": "Event location"
184                },
185                "repeat": {
186                    "type": "object",
187                    "description": "Recurrence rule",
188                    "properties": {
189                        "frequency": {
190                            "type": "string",
191                            "enum": ["daily", "weekly", "monthly", "yearly"],
192                            "description": "Recurrence frequency"
193                        },
194                        "interval": {
195                            "type": "integer",
196                            "description": "Recurrence interval (default: 1)"
197                        },
198                        "days": {
199                            "type": "array",
200                            "items": { "type": "string" },
201                            "description": "For weekly: ['mon','wed','fri']"
202                        },
203                        "count": {
204                            "type": "integer",
205                            "description": "Max number of occurrences"
206                        },
207                        "until": {
208                            "type": "string",
209                            "description": "End date for recurrence (ISO date, e.g. '2026-12-31')"
210                        }
211                    }
212                },
213                "reminder_minutes": {
214                    "type": "array",
215                    "items": { "type": "integer" },
216                    "description": "Minutes before event to trigger reminders, e.g. [5, 15, 60]"
217                },
218                "uid": {
219                    "type": "string",
220                    "description": "Event UID (required for update, delete, get)"
221                },
222                "from": {
223                    "type": "string",
224                    "description": "Range start time for list/freebusy (ISO 8601)"
225                },
226                "to": {
227                    "type": "string",
228                    "description": "Range end time for list/freebusy (ISO 8601)"
229                },
230                "query": {
231                    "type": "string",
232                    "description": "Search query for event title/description (search op)"
233                }
234            },
235            "required": ["op"]
236        })
237    }
238
239    async fn execute(
240        &self,
241        _tool_call_id: &str,
242        params: Value,
243        _signal: Option<oneshot::Receiver<()>>,
244        _ctx: &ToolContext,
245    ) -> Result<AgentToolResult, String> {
246        let op = params
247            .get("op")
248            .and_then(|v| v.as_str())
249            .ok_or_else(|| "Missing required parameter: op".to_string())?;
250
251        match op {
252            "create" => self.exec_create(&params).await,
253            "update" => self.exec_update(&params).await,
254            "delete" => self.exec_delete(&params).await,
255            "list" => self.exec_list(&params).await,
256            "get" => self.exec_get(&params).await,
257            "search" => self.exec_search(&params).await,
258            "freebusy" => self.exec_freebusy(&params).await,
259            other => Err(format!(
260                "Unknown calendar op '{other}'. Valid: create, update, delete, list, get, search, freebusy"
261            )),
262        }
263    }
264}
265
266impl CalendarTool {
267    async fn exec_create(&self, params: &Value) -> Result<AgentToolResult, String> {
268        let title = opt_str(params, "title")
269            .ok_or_else(|| "create requires 'title' parameter".to_string())?;
270        let start = opt_str(params, "start")
271            .ok_or_else(|| "create requires 'start' parameter".to_string())?;
272        let end =
273            opt_str(params, "end").ok_or_else(|| "create requires 'end' parameter".to_string())?;
274
275        let start_dt = parse_dt(start)?;
276        let end_dt = parse_dt(end)?;
277
278        let draft = EventDraft {
279            title: title.to_string(),
280            start: start_dt,
281            end: end_dt,
282            all_day: opt_bool(params, "all_day").unwrap_or(false),
283            description: opt_str(params, "description").map(|s| s.to_string()),
284            location: opt_str(params, "location").map(|s| s.to_string()),
285            repeat: opt_repeat(params),
286            reminder_minutes: opt_reminder_minutes(params).unwrap_or_default(),
287            source: oxios_calendar::EventSource::Agent,
288        };
289
290        match self.engine.create(draft).await {
291            Ok(result) => Ok(AgentToolResult::success(
292                serde_json::to_string_pretty(&json!({
293                    "uid": result.uid,
294                    "status": "created",
295                    "conflicts": result.conflicts,
296                    "file": result.file,
297                }))
298                .unwrap_or_default(),
299            )),
300            Err(e) => Ok(AgentToolResult::error(format!(
301                "Failed to create event: {e}"
302            ))),
303        }
304    }
305
306    async fn exec_update(&self, params: &Value) -> Result<AgentToolResult, String> {
307        let uid =
308            opt_str(params, "uid").ok_or_else(|| "update requires 'uid' parameter".to_string())?;
309
310        let patch = EventPatch {
311            title: opt_str(params, "title").map(|s| s.to_string()),
312            start: opt_str(params, "start").and_then(|s| parse_dt(s).ok()),
313            end: opt_str(params, "end").and_then(|s| parse_dt(s).ok()),
314            all_day: opt_bool(params, "all_day"),
315            description: opt_str(params, "description").map(|s| Some(s.to_string())),
316            location: opt_str(params, "location").map(|s| Some(s.to_string())),
317            repeat: opt_repeat(params).map(Some),
318            reminder_minutes: opt_reminder_minutes(params),
319        };
320
321        match self.engine.update(uid, patch).await {
322            Ok(result) => Ok(AgentToolResult::success(
323                serde_json::to_string_pretty(&json!({
324                    "uid": result.uid,
325                    "status": "updated",
326                    "conflicts": result.conflicts,
327                }))
328                .unwrap_or_default(),
329            )),
330            Err(e) => Ok(AgentToolResult::error(format!(
331                "Failed to update event: {e}"
332            ))),
333        }
334    }
335
336    async fn exec_delete(&self, params: &Value) -> Result<AgentToolResult, String> {
337        let uid =
338            opt_str(params, "uid").ok_or_else(|| "delete requires 'uid' parameter".to_string())?;
339
340        match self.engine.delete(uid).await {
341            Ok(()) => Ok(AgentToolResult::success(format!("Event '{uid}' deleted."))),
342            Err(e) => Ok(AgentToolResult::error(format!(
343                "Failed to delete event: {e}"
344            ))),
345        }
346    }
347
348    async fn exec_list(&self, params: &Value) -> Result<AgentToolResult, String> {
349        let from =
350            opt_str(params, "from").ok_or_else(|| "list requires 'from' parameter".to_string())?;
351        let to = opt_str(params, "to").ok_or_else(|| "list requires 'to' parameter".to_string())?;
352
353        let from_dt = parse_dt(from)?;
354        let to_dt = parse_dt(to)?;
355
356        match self.engine.list(from_dt, to_dt).await {
357            Ok(events) => {
358                if events.is_empty() {
359                    return Ok(AgentToolResult::success("No events in the given range."));
360                }
361                let display: Vec<Value> = events
362                    .iter()
363                    .map(|e| {
364                        json!({
365                            "uid": e.uid,
366                            "title": e.title,
367                            "start": e.start.to_rfc3339(),
368                            "end": e.end.to_rfc3339(),
369                            "status": e.status,
370                        })
371                    })
372                    .collect();
373                Ok(AgentToolResult::success(
374                    serde_json::to_string_pretty(&json!({
375                        "events": display,
376                        "count": display.len(),
377                    }))
378                    .unwrap_or_default(),
379                ))
380            }
381            Err(e) => Ok(AgentToolResult::error(format!(
382                "Failed to list events: {e}"
383            ))),
384        }
385    }
386
387    async fn exec_get(&self, params: &Value) -> Result<AgentToolResult, String> {
388        let uid =
389            opt_str(params, "uid").ok_or_else(|| "get requires 'uid' parameter".to_string())?;
390
391        match self.engine.get(uid).await {
392            Ok(event) => Ok(AgentToolResult::success(
393                serde_json::to_string_pretty(&json!({
394                    "uid": event.uid,
395                    "title": event.title,
396                    "start": event.start.to_rfc3339(),
397                    "end": event.end.to_rfc3339(),
398                    "all_day": event.all_day,
399                    "description": event.description,
400                    "location": event.location,
401                    "rrule": event.rrule,
402                    "status": event.status,
403                }))
404                .unwrap_or_default(),
405            )),
406            Err(e) => Ok(AgentToolResult::error(format!("Failed to get event: {e}"))),
407        }
408    }
409
410    async fn exec_search(&self, params: &Value) -> Result<AgentToolResult, String> {
411        let query = opt_str(params, "query")
412            .ok_or_else(|| "search requires 'query' parameter".to_string())?;
413
414        match self.engine.search(query).await {
415            Ok(events) => {
416                if events.is_empty() {
417                    return Ok(AgentToolResult::success(format!(
418                        "No events matching '{query}'."
419                    )));
420                }
421                let display: Vec<Value> = events
422                    .iter()
423                    .map(|e| {
424                        json!({
425                            "uid": e.uid,
426                            "title": e.title,
427                            "start": e.start.to_rfc3339(),
428                            "end": e.end.to_rfc3339(),
429                        })
430                    })
431                    .collect();
432                Ok(AgentToolResult::success(
433                    serde_json::to_string_pretty(&json!({
434                        "events": display,
435                        "count": display.len(),
436                        "query": query,
437                    }))
438                    .unwrap_or_default(),
439                ))
440            }
441            Err(e) => Ok(AgentToolResult::error(format!(
442                "Failed to search events: {e}"
443            ))),
444        }
445    }
446
447    async fn exec_freebusy(&self, params: &Value) -> Result<AgentToolResult, String> {
448        let from = opt_str(params, "from")
449            .ok_or_else(|| "freebusy requires 'from' parameter".to_string())?;
450        let to =
451            opt_str(params, "to").ok_or_else(|| "freebusy requires 'to' parameter".to_string())?;
452
453        let from_dt = parse_dt(from)?;
454        let to_dt = parse_dt(to)?;
455
456        match self.engine.freebusy(from_dt, to_dt).await {
457            Ok(slots) => Ok(AgentToolResult::success(
458                serde_json::to_string_pretty(&json!({
459                    "slots": slots,
460                    "count": slots.len(),
461                }))
462                .unwrap_or_default(),
463            )),
464            Err(e) => Ok(AgentToolResult::error(format!(
465                "Failed to compute freebusy: {e}"
466            ))),
467        }
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use chrono::Datelike;
475
476    #[test]
477    fn test_parse_dt_valid() {
478        let dt = parse_dt("2026-06-07T09:00:00Z").unwrap();
479        assert_eq!(dt.year(), 2026);
480        assert_eq!(dt.month(), 6);
481        assert_eq!(dt.day(), 7);
482    }
483
484    #[test]
485    fn test_parse_dt_invalid() {
486        assert!(parse_dt("not-a-date").is_err());
487    }
488
489    #[test]
490    fn test_opt_repeat_basic() {
491        let params = json!({
492            "repeat": {
493                "frequency": "weekly",
494                "days": ["mon", "wed"],
495                "interval": 2,
496                "count": 10
497            }
498        });
499        let rule = opt_repeat(&params).unwrap();
500        assert_eq!(rule.frequency, "weekly");
501        assert_eq!(rule.days, vec!["mon", "wed"]);
502        assert_eq!(rule.interval, 2);
503        assert_eq!(rule.count, Some(10));
504        assert!(rule.until.is_none());
505    }
506}