1use 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
26pub struct CalendarTool {
43 engine: Arc<CalendarEngine>,
44}
45
46impl CalendarTool {
47 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 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
71fn 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
82fn opt_str<'a>(params: &'a Value, key: &str) -> Option<&'a str> {
84 params.get(key).and_then(|v| v.as_str())
85}
86
87fn opt_bool(params: &Value, key: &str) -> Option<bool> {
89 params.get(key).and_then(|v| v.as_bool())
90}
91
92fn 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
104fn 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(¶ms).await,
253 "update" => self.exec_update(¶ms).await,
254 "delete" => self.exec_delete(¶ms).await,
255 "list" => self.exec_list(¶ms).await,
256 "get" => self.exec_get(¶ms).await,
257 "search" => self.exec_search(¶ms).await,
258 "freebusy" => self.exec_freebusy(¶ms).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(¶ms).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}