diff --git a/nanobot/agent/tools/calendar.py b/nanobot/agent/tools/calendar.py index ca35439..55a9e1a 100644 --- a/nanobot/agent/tools/calendar.py +++ b/nanobot/agent/tools/calendar.py @@ -23,13 +23,22 @@ class CalendarTool(Tool): name = "calendar" description = ( - "Interact with Google Calendar. REQUIRED: Always include 'action' parameter. " - "Actions: 'list_events' (list upcoming events), 'create_event' (create new event), " - "'check_availability' (check if time slot is available). " - "When user asks 'what's on my calendar' or 'show my calendar', use action='list_events'. " - "When user mentions a meeting or asks to schedule something, use action='create_event' with title and start_time. " - "CRITICAL: When scheduling from emails, you MUST call this tool - do not just acknowledge. " - "Use natural language time formats like 'March 6 19:00' or 'tomorrow 2pm' - DO NOT use ISO format with wrong years." + "Interact with Google Calendar. REQUIRED: Always include 'action' parameter.\n\n" + "Actions:\n" + "- list_events: List upcoming events with details (title, time, ID). ALWAYS call this FIRST before update_event or delete_event.\n" + "- create_event: Create new event. Requires title and start_time.\n" + "- update_event: Update/reschedule an event. Requires event_id (get from list_events) and start_time (new time).\n" + "- delete_event: Delete a single event. Requires event_id (get from list_events).\n" + "- delete_events: Delete multiple events. Requires event_ids array (get from list_events).\n" + "- check_availability: Check if a time slot is available.\n\n" + "CRITICAL WORKFLOW for deletion (cancel/delete meetings):\n" + "DO NOT EXPLAIN - EXECUTE IMMEDIATELY:\n" + "1. IMMEDIATELY call calendar(action='list_events', time_min='today') - DO NOT explain, just execute\n" + "2. Extract ALL event IDs from the response (long strings after '[ID: ' or in 'Event IDs:' line)\n" + "3. IMMEDIATELY call calendar(action='delete_events', event_ids=[...]) with the extracted IDs\n" + "NEVER use placeholder values like 'ID1', '[get event ID...]', or 'list_events'.\n" + "NEVER explain what you will do - just execute the tools immediately.\n" + "When you call this tool, the system will execute it automatically. Do not show JSON in your response - just call the tool." ) def __init__(self, calendar_config: Any = None): @@ -51,6 +60,16 @@ class CalendarTool(Tool): config = load_config() self._calendar_config = config.tools.calendar return self._calendar_config + + def _get_timezone(self): + """Get configured timezone or default to UTC.""" + import pytz + config = self.config + tz_str = getattr(config, 'timezone', 'UTC') or 'UTC' + try: + return pytz.timezone(tz_str) + except Exception: + return pytz.UTC def _get_credentials(self) -> Credentials | None: """Get valid user credentials from storage or OAuth flow.""" @@ -119,6 +138,18 @@ class CalendarTool(Tool): def coerce_params(self, params: dict[str, Any]) -> dict[str, Any]: """Coerce parameters, handling common name mismatches.""" + # Handle nested "parameters" key (some LLMs wrap params this way) + if "parameters" in params and isinstance(params["parameters"], dict): + # Extract nested parameters and merge with top-level + nested = params.pop("parameters") + params = {**params, **nested} + + # Remove common non-parameter keys that LLMs sometimes include + params.pop("function", None) + params.pop("functionName", None) + params.pop("function_name", None) + params.pop("tz", None) # Not a valid parameter + coerced = super().coerce_params(params) # Handle case where action is passed as a string argument instead of named parameter @@ -126,7 +157,7 @@ class CalendarTool(Tool): if "action" not in coerced and len(coerced) == 1: # Check if there's a single string value that could be the action for key, value in coerced.items(): - if isinstance(value, str) and value in ["list_events", "create_event", "check_availability", "calendar"]: + if isinstance(value, str) and value in ["list_events", "create_event", "delete_event", "delete_events", "update_event", "check_availability", "calendar"]: coerced["action"] = "list_events" if value == "calendar" else value coerced.pop(key, None) break @@ -140,8 +171,17 @@ class CalendarTool(Tool): "properties": { "action": { "type": "string", - "enum": ["list_events", "create_event", "check_availability"], - "description": "Action to perform: list_events (list upcoming events), create_event (create a new event), check_availability (check if time slot is available)", + "enum": ["list_events", "create_event", "delete_event", "delete_events", "update_event", "check_availability"], + "description": "Action to perform: list_events (list upcoming events with details), create_event (create a new event), delete_event (delete a single event by ID), delete_events (delete multiple events by IDs), update_event (update/reschedule an event - requires event_id), check_availability (check if time slot is available)", + }, + "event_id": { + "type": "string", + "description": "Event ID (required for delete_event). Get event IDs by calling list_events first.", + }, + "event_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of event IDs (required for delete_events). Get event IDs by calling list_events first.", }, "title": { "type": "string", @@ -190,20 +230,31 @@ class CalendarTool(Tool): def _parse_time(self, time_str: str) -> datetime: """Parse time string (ISO format or relative like 'tomorrow 2pm'). - Returns timezone-aware datetime in UTC. + Returns timezone-aware datetime in the configured timezone (or UTC if not configured). """ import re from datetime import timezone + import pytz + + # Get configured timezone or default to UTC + config = self.config + tz_str = getattr(config, 'timezone', 'UTC') or 'UTC' + try: + tz = pytz.timezone(tz_str) + except Exception: + # Invalid timezone, fall back to UTC + tz = pytz.UTC original_str = time_str time_str = time_str.strip().lower() # Try ISO format first, but validate year is reasonable + now = datetime.now(tz) try: dt = datetime.fromisoformat(time_str.replace("Z", "+00:00")) - # Ensure timezone-aware + # Ensure timezone-aware - if no timezone, assume configured timezone if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) + dt = tz.localize(dt) # Validate year is current year or future (prevent scheduling in past years) current_year = now.year @@ -244,7 +295,7 @@ class CalendarTool(Tool): year = now.year # Check if date is in the past - test_date = datetime(year, month, day, tzinfo=timezone.utc) + test_date = tz.localize(datetime(year, month, day)) if test_date < now.replace(hour=0, minute=0, second=0, microsecond=0): year += 1 @@ -262,16 +313,15 @@ class CalendarTool(Tool): # 24-hour format hour = int(groups[2]) minute = int(groups[3]) - result = datetime(year, month, day, hour, minute, tzinfo=timezone.utc) + result = tz.localize(datetime(year, month, day, hour, minute)) return result else: # Date only, default to 9am - result = datetime(year, month, day, 9, 0, tzinfo=timezone.utc) + result = tz.localize(datetime(year, month, day, 9, 0)) return result # Parse relative times - from datetime import timezone - now = datetime.now(timezone.utc) + now = datetime.now(tz) if time_str.startswith("in "): # "in 1 hour", "in 30 minutes" parts = time_str[3:].split() @@ -290,11 +340,7 @@ class CalendarTool(Tool): # Handle "today" - same logic as "tomorrow" but use today's date if "today" in time_str: - from datetime import timezone base = now.replace(hour=0, minute=0, second=0, microsecond=0) - # Ensure base is timezone-aware - if base.tzinfo is None: - base = base.replace(tzinfo=timezone.utc) # Try to extract time - same patterns as tomorrow hour = None minute = 0 @@ -310,8 +356,6 @@ class CalendarTool(Tool): elif period == "am" and hour == 12: hour = 0 result = base.replace(hour=hour, minute=minute, second=0, microsecond=0) - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) # If time has passed today, it's invalid - return error or use tomorrow? if result < now: # Time has passed, schedule for tomorrow instead @@ -328,8 +372,6 @@ class CalendarTool(Tool): elif period == "am" and hour == 12: hour = 0 result = base.replace(hour=hour, minute=0, second=0, microsecond=0) - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) # If time has passed today, schedule for tomorrow if result < now: result = result + timedelta(days=1) @@ -343,8 +385,6 @@ class CalendarTool(Tool): # Validate hour is in 24-hour range if 0 <= hour <= 23 and 0 <= minute <= 59: result = base.replace(hour=hour, minute=minute, second=0, microsecond=0) - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) # If time has passed today, schedule for tomorrow if result < now: result = result + timedelta(days=1) @@ -357,8 +397,6 @@ class CalendarTool(Tool): minute = int(match.group(2)) if 0 <= hour <= 23 and 0 <= minute <= 59: result = base.replace(hour=hour, minute=minute, second=0, microsecond=0) - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) if result < now: result = result + timedelta(days=1) return result @@ -371,26 +409,18 @@ class CalendarTool(Tool): if hour <= 12 and hour > 0: hour += 12 # Default to PM for afternoon times result = base.replace(hour=hour, minute=0, second=0, microsecond=0) - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) if result < now: result = result + timedelta(days=1) return result # Default to 9am today (or tomorrow if 9am has passed) result = base.replace(hour=9, minute=0, second=0, microsecond=0) - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) if result < now: result = result + timedelta(days=1) return result if "tomorrow" in time_str: - from datetime import timezone base = now + timedelta(days=1) - # Ensure base is timezone-aware - if base.tzinfo is None: - base = base.replace(tzinfo=timezone.utc) # Try to extract time - improved regex to handle various formats # Try patterns in order of specificity (most specific first) hour = None @@ -407,9 +437,6 @@ class CalendarTool(Tool): elif period == "am" and hour == 12: hour = 0 result = base.replace(hour=hour, minute=minute, second=0, microsecond=0) - # Ensure timezone is preserved - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) return result # Pattern 2: "2pm" or "2 pm" (hour only with am/pm) - must check this before 24-hour patterns @@ -423,8 +450,6 @@ class CalendarTool(Tool): elif period == "am" and hour == 12: hour = 0 result = base.replace(hour=hour, minute=0, second=0, microsecond=0) - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) return result # Pattern 3: "14:00" or "tomorrow 14:00" (24-hour format with colon, no am/pm) @@ -436,8 +461,6 @@ class CalendarTool(Tool): # Validate hour is in 24-hour range if 0 <= hour <= 23 and 0 <= minute <= 59: result = base.replace(hour=hour, minute=minute, second=0, microsecond=0) - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) return result # Pattern 4: "at 2:00" (24-hour format without am/pm, with "at") @@ -447,8 +470,6 @@ class CalendarTool(Tool): minute = int(match.group(2)) if 0 <= hour <= 23 and 0 <= minute <= 59: result = base.replace(hour=hour, minute=minute, second=0, microsecond=0) - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) return result # Pattern 5: "at 2" (hour only, assume 24-hour if > 12, else assume pm) @@ -459,22 +480,69 @@ class CalendarTool(Tool): if hour <= 12 and hour > 0: hour += 12 # Default to PM for afternoon times result = base.replace(hour=hour, minute=0, second=0, microsecond=0) - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) return result # Default to 9am if no time specified result = base.replace(hour=9, minute=0, second=0, microsecond=0) - if result.tzinfo is None: - result = result.replace(tzinfo=timezone.utc) return result # Default: assume it's today at the specified time or now - from datetime import timezone - if now.tzinfo is None: - now = now.replace(tzinfo=timezone.utc) return now + def _parse_time_with_date(self, time_str: str, base_date: Any) -> datetime: + """Parse a time string (like '4pm') in the context of a specific date. + + This is used when updating events - if user says 'move to 4pm', + we want to use the original event's date, not 'today' relative to now. + """ + import re + from datetime import date + + tz = self._get_timezone() + # Create a datetime for the base date at midnight in the configured timezone + base_dt = tz.localize(datetime.combine(base_date, datetime.min.time())) + + time_str_lower = time_str.strip().lower() + + # Pattern 1: "4pm" or "4 pm" (hour only with am/pm) + match = re.search(r"(\d{1,2})\s*(am|pm)\b", time_str_lower, re.IGNORECASE) + if match: + hour = int(match.group(1)) + period = match.group(2).lower() + if period == "pm" and hour != 12: + hour += 12 + elif period == "am" and hour == 12: + hour = 0 + return base_dt.replace(hour=hour, minute=0, second=0, microsecond=0) + + # Pattern 2: "4:00pm" or "4:00 pm" (with minutes and am/pm) + match = re.search(r"(\d{1,2})\s*:\s*(\d{2})\s*(am|pm)", time_str_lower, re.IGNORECASE) + if match: + hour = int(match.group(1)) + minute = int(match.group(2)) + period = match.group(3).lower() + if period == "pm" and hour != 12: + hour += 12 + elif period == "am" and hour == 12: + hour = 0 + return base_dt.replace(hour=hour, minute=minute, second=0, microsecond=0) + + # Pattern 3: "16:00" (24-hour format) + match = re.search(r"(\d{1,2})\s*:\s*(\d{2})(?!\s*(am|pm))", time_str_lower, re.IGNORECASE) + if match: + hour = int(match.group(1)) + minute = int(match.group(2)) + if 0 <= hour <= 23 and 0 <= minute <= 59: + return base_dt.replace(hour=hour, minute=minute, second=0, microsecond=0) + + # If no simple time pattern matches, fall back to full _parse_time + # but adjust the date to match base_date + parsed = self._parse_time(time_str) + if parsed.date() != base_date: + # Adjust to use the base_date, keeping the time + return parsed.replace(year=base_date.year, month=base_date.month, day=base_date.day) + return parsed + async def execute( self, action: str, @@ -486,6 +554,8 @@ class CalendarTool(Tool): attendees: list[str] | None = None, max_results: int = 10, time_min: str | None = None, + event_id: str | None = None, + event_ids: list[str] | None = None, **kwargs: Any, ) -> str: """ @@ -539,12 +609,75 @@ class CalendarTool(Tool): location, attendees, ) + elif action == "update_event": + if not event_id: + return ( + "ERROR: event_id is required for update_event. " + "YOU MUST call calendar(action='list_events', time_min='today') FIRST to get the actual event ID. " + "Do NOT use placeholder values." + ) + # Validate event_id is not a placeholder + if any(placeholder in event_id.lower() for placeholder in [ + "get event", "from calendar", "list_events", " str: + """Delete a calendar event by ID.""" + import asyncio + + def _delete(): + try: + service.events().delete(calendarId=calendar_id, eventId=event_id).execute() + return f"Event deleted successfully (ID: {event_id})" + except HttpError as e: + if e.resp.status == 404: + return f"Error: Event not found (ID: {event_id}). It may have already been deleted." + return f"Error deleting event: {e}" + except Exception as e: + return f"Error deleting event: {str(e)}" + + return await asyncio.to_thread(_delete) + + async def _delete_events( + self, service: Any, calendar_id: str, event_ids: list[str] + ) -> str: + """Delete multiple calendar events by IDs.""" + import asyncio + + def _delete_all(): + # Filter out placeholder/invalid IDs + valid_ids = [] + invalid_ids = [] + + for event_id in event_ids: + if not isinstance(event_id, str): + invalid_ids.append(str(event_id)) + continue + + # Check for common invalid patterns: + # 1. Placeholders like "ID1", "ID2", etc. + # 2. Error messages or instructions (contain common words) + # 3. Too short (real Google Calendar IDs are typically 20+ characters) + # 4. Contains spaces (real IDs don't have spaces) + invalid_patterns = [ + event_id.upper() in ["ID1", "ID2", "ID3", "ID4", "ID5"], + len(event_id) < 15, # Real Google Calendar IDs are longer + " " in event_id, # Real IDs don't have spaces + any(word in event_id.lower() for word in [ + "get", "event", "id", "by", "calling", "list", "first", + "extract", "from", "response", "error", "invalid" + ]), # Likely an instruction/error message + ] + + if any(invalid_patterns): + invalid_ids.append(event_id) + else: + valid_ids.append(event_id) + + if invalid_ids: + return ( + f"ERROR: Invalid event IDs detected: {invalid_ids}. " + f"STOP and call calendar(action='list_events', time_min='today') FIRST to get actual event IDs. " + f"Do NOT call delete_events again until you have called list_events and extracted the real IDs. " + f"Event IDs are long alphanumeric strings (20+ characters, no spaces) from the list_events response." + ) + + if not valid_ids: + return ( + "ERROR: No valid event IDs provided. " + "YOU MUST call calendar(action='list_events', time_min='today') FIRST. " + "Do NOT call delete_events again. Call list_events now, extract the IDs from the response " + "(they appear after '[ID: ' or in the 'Event IDs:' line), then call delete_events with those IDs." + ) + + deleted = [] + failed = [] + + for event_id in valid_ids: + try: + service.events().delete(calendarId=calendar_id, eventId=event_id).execute() + deleted.append(event_id) + except HttpError as e: + if e.resp.status == 404: + # Already deleted, count as success + deleted.append(event_id) + else: + failed.append((event_id, str(e))) + except Exception as e: + failed.append((event_id, str(e))) + + result_parts = [] + if deleted: + result_parts.append(f"Successfully deleted {len(deleted)} event(s).") + if failed: + result_parts.append(f"Failed to delete {len(failed)} event(s): {failed}") + + return " ".join(result_parts) if result_parts else "No events to delete." + + return await asyncio.to_thread(_delete_all) + + async def _update_event( + self, + service: Any, + calendar_id: str, + event_id: str, + title: str | None = None, + start_time: str | None = None, + end_time: str | None = None, + description: str | None = None, + location: str | None = None, + attendees: list[str] | None = None, + ) -> str: + """Update/reschedule a calendar event.""" + import asyncio + + def _update(): + try: + # Get existing event + event = service.events().get(calendarId=calendar_id, eventId=event_id).execute() + + # Save ORIGINAL start/end times BEFORE we modify them (needed for duration calculation) + original_start_raw = event["start"].get("dateTime", event["start"].get("date")) + original_end_raw = event["end"].get("dateTime", event["end"].get("date")) + + # Get original event date for context + try: + if "T" in original_start_raw: + original_start_dt = datetime.fromisoformat(original_start_raw.replace("Z", "+00:00")) + original_date = original_start_dt.date() + old_start_dt = original_start_dt + else: + original_date = datetime.fromisoformat(original_start_raw).date() + tz = self._get_timezone() + old_start_dt = tz.localize(datetime.combine(original_date, datetime.min.time())) + except Exception: + # Fallback to today if parsing fails + tz = self._get_timezone() + original_date = datetime.now(tz).date() + old_start_dt = datetime.now(tz) + + try: + if "T" in original_end_raw: + old_end_dt = datetime.fromisoformat(original_end_raw.replace("Z", "+00:00")) + else: + tz = self._get_timezone() + old_end_dt = tz.localize(datetime.combine(original_date, datetime.min.time())) + except Exception: + old_end_dt = old_start_dt + timedelta(hours=1) # Default 1 hour duration + + # Update fields if provided + if title: + event["summary"] = title + if start_time: + # Parse start_time in context of the original event's date + # If it's just a time like "4pm", use the original event's date + start_dt = self._parse_time_with_date(start_time, original_date) + config = self.config + tz_str = getattr(config, 'timezone', 'UTC') or 'UTC' + event["start"] = { + "dateTime": start_dt.isoformat(), + "timeZone": tz_str, + } + if end_time: + # Parse end_time in context of the original event's date (or new start date if start_time was provided) + base_date = original_date + if start_time and "start" in event: + try: + new_start_raw = event["start"].get("dateTime", "") + if "T" in new_start_raw: + base_date = datetime.fromisoformat(new_start_raw.replace("Z", "+00:00")).date() + except Exception: + pass + end_dt = self._parse_time_with_date(end_time, base_date) + config = self.config + tz_str = getattr(config, 'timezone', 'UTC') or 'UTC' + event["end"] = { + "dateTime": end_dt.isoformat(), + "timeZone": tz_str, + } + elif start_time and "start" in event: + # If start_time changed but end_time not provided, adjust end_time to maintain duration + # Use the ORIGINAL start/end times we saved above + duration = old_end_dt - old_start_dt + + # Get the NEW start time (already updated above) + new_start_dt = datetime.fromisoformat(event["start"].get("dateTime", "").replace("Z", "+00:00")) + new_end_dt = new_start_dt + duration + config = self.config + tz_str = getattr(config, 'timezone', 'UTC') or 'UTC' + event["end"] = { + "dateTime": new_end_dt.isoformat(), + "timeZone": tz_str, + } + + if description is not None: + event["description"] = description + if location is not None: + event["location"] = location + if attendees is not None: + event["attendees"] = [{"email": email} for email in attendees] + + # Update the event + updated_event = service.events().update( + calendarId=calendar_id, + eventId=event_id, + body=event + ).execute() + + return f"Event updated successfully: {updated_event.get('htmlLink')}" + except HttpError as e: + if e.resp.status == 404: + return f"Error: Event not found (ID: {event_id}). It may have been deleted." + return f"Error updating event: {e}" + except Exception as e: + return f"Error updating event: {str(e)}" + + return await asyncio.to_thread(_update) + diff --git a/workspace/AGENTS.md b/workspace/AGENTS.md index a4b012b..79efb28 100644 --- a/workspace/AGENTS.md +++ b/workspace/AGENTS.md @@ -2,6 +2,12 @@ You are a helpful AI assistant. Be concise, accurate, and friendly. +**CRITICAL: TOOL EXECUTION** +- When you need to use a tool, CALL IT DIRECTLY - the system will execute it automatically +- DO NOT show JSON like `{"action": "list_events"}` in your response text +- DO NOT explain what tool you would call - just call it +- Tools are executed automatically when you call them - you don't need to show the JSON + ## 🚨 CRITICAL: Gitea API Requests **When user asks to list PRs, issues, or use Gitea API:** @@ -32,9 +38,10 @@ curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api ## Guidelines -- Always explain what you're doing before taking actions +- **CRITICAL: When you need to use a tool, the system will automatically execute it when you call it. You do NOT need to show JSON.** +- **When user asks you to do something, IMMEDIATELY call the necessary tools - do not explain, do not show JSON, just call them.** +- The system handles tool execution automatically - you just need to call the tools in your response. - Ask for clarification when the request is ambiguous -- Use tools to help accomplish tasks - Remember important information in your memory files ## Git Operations @@ -126,6 +133,8 @@ When the scheduled time arrives, the cron system will send the message back to y **CRITICAL: When processing emails that mention meetings, you MUST automatically schedule them in the calendar.** +**CRITICAL: When using calendar tools, EXECUTE them immediately. Do NOT show JSON or explain what you would do - just call the tool.** + When an email mentions a meeting (e.g., "meeting tomorrow at 2pm", "reminder about our meeting on March 7 at 15:00", "call scheduled for next week"), you MUST: 1. **Extract meeting details** from the email: @@ -162,6 +171,57 @@ When an email mentions a meeting (e.g., "meeting tomorrow at 2pm", "reminder abo - Relative: `"tomorrow 2pm"`, `"in 1 hour"`, `"in 2 days"` - ISO format: `"2024-01-15T14:00:00"` +**Deleting/Canceling Events:** +When the user asks to cancel or delete meetings, you MUST follow this workflow - DO NOT explain, just execute: + +**STEP 1: ALWAYS call list_events FIRST - DO THIS NOW, DO NOT EXPLAIN** +- IMMEDIATELY call `calendar(action="list_events", time_min="today")` +- Do NOT explain what you will do - just call the tool +- Do NOT try to use `delete_events_today` (it doesn't exist) + +**STEP 2: From the list_events response, identify the target event(s)** +- "Cancel all meetings today" → ALL events from today (extract ALL IDs from the response) +- "Cancel my last meeting" → The last event in the list (marked as "LAST - latest time") +- "Cancel my 8pm meeting" → Event(s) at 8pm +- "Cancel the meeting with John" → Event(s) with "John" in title/description + +**STEP 3: Extract event IDs from the response** +- Event IDs are long strings (20+ characters) after `[ID: ` or in the `Event IDs:` line +- For "cancel all", extract ALL IDs from the response + +**STEP 4: Call delete_event or delete_events with the extracted IDs** +- Single event: `calendar(action="delete_event", event_id="...")` +- Multiple events: `calendar(action="delete_events", event_ids=[...])` +- **CRITICAL**: Do NOT use placeholder IDs - you MUST extract real IDs from list_events response +- **CRITICAL**: Do NOT use `update_event` with `status: "cancelled"` (that doesn't work) + +**Rescheduling/Moving Events:** +When the user asks to reschedule or move a meeting, you MUST follow these steps: + +**STEP 1: ALWAYS call list_events FIRST - DO THIS NOW, DO NOT EXPLAIN** +- IMMEDIATELY call `calendar(action="list_events", time_min="today")` +- Do NOT explain what you will do - just call the tool +- Do NOT use placeholder values - you MUST get the actual ID from the response + +**STEP 2: From the list_events response, identify the target event** +- "last meeting" → The event with the LATEST time (marked as "LAST - latest time" in the response, usually the last numbered item) +- "first meeting" → The event with the EARLIEST time (marked as "FIRST - earliest time", usually #1) +- "8pm meeting" → Event(s) at 8pm (look for "8:00 PM" or "20:00" in the time) +- "meeting with John" → Event(s) with "John" in the title +- Extract the actual event_id (long string after `[ID: `, usually 20+ characters) +- IMPORTANT: Events are numbered in the response - use the number and the "LAST" marker to identify correctly + +**STEP 3: IMMEDIATELY call update_event with the actual event_id** +- Call `calendar(action="update_event", event_id="actual_id_from_step_2", start_time="new time")` +- Use natural language for new time: "4pm", "next Monday at 4pm", "tomorrow 2pm", etc. +- Do NOT explain - just execute the tool call + +**CRITICAL:** +- When you get an error saying "Invalid event_id" or "placeholder", DO NOT explain the solution +- Instead, IMMEDIATELY call list_events, then call update_event again with the real ID +- NEVER show JSON - just call the tools +- NEVER use placeholder values - always get real IDs from list_events + **Automatic scheduling:** When `auto_schedule_from_email` is enabled (default: true), automatically schedule meetings when detected in emails. Do NOT just acknowledge - actually create the calendar event using the `calendar` tool. **Examples of emails that should trigger scheduling:**