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 async_trait::async_trait;
17use std::sync::Arc;
18
19use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
20use serde_json::{Value, json};
21
22use crate::kernel_handle::KernelHandle;
23use oxios_calendar::{CalendarEngine, EventDraft, EventPatch, Repeat};
24
25/// Agent tool for calendar event management.
26///
27/// Wraps the [`CalendarApi`](crate::kernel_handle::CalendarApi) domain. Allows agents
28/// to create, update, delete, list, get, search events and query free-busy slots.
29///
30/// ## Operations
31///
32/// | Op         | Description               | Required params                     | Optional params                          |
33/// |------------|---------------------------|-------------------------------------|------------------------------------------|
34/// | `create`   | Create a new event        | `title`, `start`, `end`             | `all_day`, `description`, `location`, `repeat`, `reminder_minutes` |
35/// | `update`   | Update an existing event  | `uid`                               | `title`, `start`, `end`, `all_day`, `description`, `location`, `repeat`, `reminder_minutes` |
36/// | `delete`   | Delete an event           | `uid`                               | —                                        |
37/// | `list`     | List events in range      | `from`, `to`                        | —                                        |
38/// | `get`      | Get a single event        | `uid`                               | —                                        |
39/// | `search`   | Full-text search events   | `query`                             | —                                        |
40/// | `freebusy` | Free/busy slots in range  | `from`, `to`                        | —                                        |
41pub struct CalendarTool {
42    engine: Arc<CalendarEngine>,
43}
44
45impl CalendarTool {
46    /// Create a new `CalendarTool` from a `KernelHandle`.
47    ///
48    /// Returns `None` if calendar is not configured.
49    pub fn try_from_kernel(kernel: &KernelHandle) -> Option<Self> {
50        kernel.calendar.as_ref().map(|api| Self {
51            engine: api.engine.clone(),
52        })
53    }
54
55    /// Create a new `CalendarTool` from a `KernelHandle` (required).
56    ///
57    /// Panics if calendar is not configured. Use [`try_from_kernel`] for
58    /// the optional variant.
59    pub fn from_kernel(kernel: &KernelHandle) -> Self {
60        Self::try_from_kernel(kernel).expect("CalendarTool requires calendar to be configured")
61    }
62}
63
64impl std::fmt::Debug for CalendarTool {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("CalendarTool").finish()
67    }
68}
69
70/// Parse an ISO 8601 datetime string into a `chrono::DateTime<chrono::Utc>`.
71fn parse_dt(s: &str) -> Result<chrono::DateTime<chrono::Utc>, String> {
72    chrono::DateTime::parse_from_rfc3339(s)
73        .map(|dt| dt.with_timezone(&chrono::Utc))
74        .map_err(|e| {
75            format!(
76                "Invalid datetime '{s}': {e}. Use ISO 8601 / RFC 3339 format, e.g. \"2026-06-07T09:00:00Z\""
77            )
78        })
79}
80
81/// Extract an optional string parameter.
82fn opt_str<'a>(params: &'a Value, key: &str) -> Option<&'a str> {
83    params.get(key).and_then(|v| v.as_str())
84}
85
86/// Extract an optional boolean parameter.
87fn opt_bool(params: &Value, key: &str) -> Option<bool> {
88    params.get(key).and_then(|v| v.as_bool())
89}
90
91/// Extract an optional `reminder_minutes` array (Vec<u32>).
92fn opt_reminder_minutes(params: &Value) -> Option<Vec<u32>> {
93    params
94        .get("reminder_minutes")
95        .and_then(|v| v.as_array())
96        .map(|arr| {
97            arr.iter()
98                .filter_map(|v| v.as_u64().map(|n| n as u32))
99                .collect()
100        })
101}
102
103/// Extract an optional `repeat` object and parse into a [`Repeat`].
104fn opt_repeat(params: &Value) -> Option<Repeat> {
105    let obj = params.get("repeat")?.as_object()?;
106    let frequency = obj
107        .get("frequency")
108        .and_then(|v| v.as_str())
109        .unwrap_or("daily")
110        .to_string();
111    let interval = obj.get("interval").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
112    let days = obj
113        .get("days")
114        .and_then(|v| v.as_array())
115        .map(|arr| {
116            arr.iter()
117                .filter_map(|v| v.as_str().map(|s| s.to_string()))
118                .collect()
119        })
120        .unwrap_or_default();
121    let until = obj
122        .get("until")
123        .and_then(|v| v.as_str())
124        .map(|s| s.to_string());
125    let count = obj.get("count").and_then(|v| v.as_u64()).map(|v| v as u32);
126
127    Some(Repeat {
128        frequency,
129        days,
130        interval,
131        until,
132        count,
133    })
134}
135
136#[async_trait]
137
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<tokio::sync::oneshot::Receiver<()>>,
244        _ctx: &ToolContext,
245    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
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            note_path: opt_str(params, "note_path").map(|s| s.to_string()),
289        };
290
291        match self.engine.create(draft).await {
292            Ok(result) => Ok(AgentToolResult::success(
293                serde_json::to_string_pretty(&json!({
294                    "uid": result.uid,
295                    "status": "created",
296                    "conflicts": result.conflicts,
297                    "file": result.file,
298                }))
299                .unwrap_or_default(),
300            )),
301            Err(e) => Ok(AgentToolResult::error(format!(
302                "Failed to create event: {e}"
303            ))),
304        }
305    }
306
307    async fn exec_update(&self, params: &Value) -> Result<AgentToolResult, String> {
308        let uid =
309            opt_str(params, "uid").ok_or_else(|| "update requires 'uid' parameter".to_string())?;
310
311        let patch = EventPatch {
312            title: opt_str(params, "title").map(|s| s.to_string()),
313            start: opt_str(params, "start").and_then(|s| parse_dt(s).ok()),
314            end: opt_str(params, "end").and_then(|s| parse_dt(s).ok()),
315            all_day: opt_bool(params, "all_day"),
316            description: opt_str(params, "description").map(|s| Some(s.to_string())),
317            location: opt_str(params, "location").map(|s| Some(s.to_string())),
318            repeat: opt_repeat(params).map(Some),
319            note_path: opt_str(params, "note_path").map(|s| Some(s.to_string())),
320            reminder_minutes: opt_reminder_minutes(params),
321        };
322
323        match self.engine.update(uid, patch).await {
324            Ok(result) => Ok(AgentToolResult::success(
325                serde_json::to_string_pretty(&json!({
326                    "uid": result.uid,
327                    "status": "updated",
328                    "conflicts": result.conflicts,
329                }))
330                .unwrap_or_default(),
331            )),
332            Err(e) => Ok(AgentToolResult::error(format!(
333                "Failed to update event: {e}"
334            ))),
335        }
336    }
337
338    async fn exec_delete(&self, params: &Value) -> Result<AgentToolResult, String> {
339        let uid =
340            opt_str(params, "uid").ok_or_else(|| "delete requires 'uid' parameter".to_string())?;
341
342        match self.engine.delete(uid).await {
343            Ok(()) => Ok(AgentToolResult::success(format!("Event '{uid}' deleted."))),
344            Err(e) => Ok(AgentToolResult::error(format!(
345                "Failed to delete event: {e}"
346            ))),
347        }
348    }
349
350    async fn exec_list(&self, params: &Value) -> Result<AgentToolResult, String> {
351        let from =
352            opt_str(params, "from").ok_or_else(|| "list requires 'from' parameter".to_string())?;
353        let to = opt_str(params, "to").ok_or_else(|| "list requires 'to' parameter".to_string())?;
354
355        let from_dt = parse_dt(from)?;
356        let to_dt = parse_dt(to)?;
357
358        match self.engine.list(from_dt, to_dt).await {
359            Ok(events) => {
360                if events.is_empty() {
361                    return Ok(AgentToolResult::success("No events in the given range."));
362                }
363                let display: Vec<Value> = events
364                    .iter()
365                    .map(|e| {
366                        json!({
367                            "uid": e.uid,
368                            "title": e.title,
369                            "start": e.start.to_rfc3339(),
370                            "end": e.end.to_rfc3339(),
371                            "status": e.status,
372                        })
373                    })
374                    .collect();
375                Ok(AgentToolResult::success(
376                    serde_json::to_string_pretty(&json!({
377                        "events": display,
378                        "count": display.len(),
379                    }))
380                    .unwrap_or_default(),
381                ))
382            }
383            Err(e) => Ok(AgentToolResult::error(format!(
384                "Failed to list events: {e}"
385            ))),
386        }
387    }
388
389    async fn exec_get(&self, params: &Value) -> Result<AgentToolResult, String> {
390        let uid =
391            opt_str(params, "uid").ok_or_else(|| "get requires 'uid' parameter".to_string())?;
392
393        match self.engine.get(uid).await {
394            Ok(event) => Ok(AgentToolResult::success(
395                serde_json::to_string_pretty(&json!({
396                    "uid": event.uid,
397                    "title": event.title,
398                    "start": event.start.to_rfc3339(),
399                    "end": event.end.to_rfc3339(),
400                    "all_day": event.all_day,
401                    "description": event.description,
402                    "location": event.location,
403                    "rrule": event.rrule,
404                    "status": event.status,
405                }))
406                .unwrap_or_default(),
407            )),
408            Err(e) => Ok(AgentToolResult::error(format!("Failed to get event: {e}"))),
409        }
410    }
411
412    async fn exec_search(&self, params: &Value) -> Result<AgentToolResult, String> {
413        let query = opt_str(params, "query")
414            .ok_or_else(|| "search requires 'query' parameter".to_string())?;
415
416        match self.engine.search(query).await {
417            Ok(events) => {
418                if events.is_empty() {
419                    return Ok(AgentToolResult::success(format!(
420                        "No events matching '{query}'."
421                    )));
422                }
423                let display: Vec<Value> = events
424                    .iter()
425                    .map(|e| {
426                        json!({
427                            "uid": e.uid,
428                            "title": e.title,
429                            "start": e.start.to_rfc3339(),
430                            "end": e.end.to_rfc3339(),
431                        })
432                    })
433                    .collect();
434                Ok(AgentToolResult::success(
435                    serde_json::to_string_pretty(&json!({
436                        "events": display,
437                        "count": display.len(),
438                        "query": query,
439                    }))
440                    .unwrap_or_default(),
441                ))
442            }
443            Err(e) => Ok(AgentToolResult::error(format!(
444                "Failed to search events: {e}"
445            ))),
446        }
447    }
448
449    async fn exec_freebusy(&self, params: &Value) -> Result<AgentToolResult, String> {
450        let from = opt_str(params, "from")
451            .ok_or_else(|| "freebusy requires 'from' parameter".to_string())?;
452        let to =
453            opt_str(params, "to").ok_or_else(|| "freebusy requires 'to' parameter".to_string())?;
454
455        let from_dt = parse_dt(from)?;
456        let to_dt = parse_dt(to)?;
457
458        match self.engine.freebusy(from_dt, to_dt).await {
459            Ok(slots) => Ok(AgentToolResult::success(
460                serde_json::to_string_pretty(&json!({
461                    "slots": slots,
462                    "count": slots.len(),
463                }))
464                .unwrap_or_default(),
465            )),
466            Err(e) => Ok(AgentToolResult::error(format!(
467                "Failed to compute freebusy: {e}"
468            ))),
469        }
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use chrono::Datelike;
477
478    #[test]
479    fn test_parse_dt_valid() {
480        let dt = parse_dt("2026-06-07T09:00:00Z").unwrap();
481        assert_eq!(dt.year(), 2026);
482        assert_eq!(dt.month(), 6);
483        assert_eq!(dt.day(), 7);
484    }
485
486    #[test]
487    fn test_parse_dt_invalid() {
488        assert!(parse_dt("not-a-date").is_err());
489    }
490
491    #[test]
492    fn test_opt_repeat_basic() {
493        let params = json!({
494            "repeat": {
495                "frequency": "weekly",
496                "days": ["mon", "wed"],
497                "interval": 2,
498                "count": 10
499            }
500        });
501        let rule = opt_repeat(&params).unwrap();
502        assert_eq!(rule.frequency, "weekly");
503        assert_eq!(rule.days, vec!["mon", "wed"]);
504        assert_eq!(rule.interval, 2);
505        assert_eq!(rule.count, Some(10));
506        assert!(rule.until.is_none());
507    }
508}