Fix calendar tool: improve event deletion and time parsing

- Add validation for placeholder event IDs in delete_event action
- Fix time parsing in update_event to use original event's date context
- Add _parse_time_with_date helper for date-aware time parsing
- Improve error messages to be more directive (STOP, DO NOT EXPLAIN)
- Update tool description to emphasize immediate execution
- Fix duration calculation in update_event to use original start/end times
- Improve list_events output with numbered events and LAST/FIRST markers
- Update AGENTS.md with explicit deletion workflow instructions
- Remove reference to non-existent delete_events_today action
This commit is contained in:
tanyar09 2026-03-06 12:42:27 -05:00
parent bc5f169bc8
commit bc53dc6535
2 changed files with 505 additions and 71 deletions

View File

@ -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", "<id", "placeholder",
"[get", "event id", "id of", "id from", "[id from", "previous call"
]) or len(event_id) < 15:
return (
f"STOP: Invalid event_id '{event_id}' - this is a placeholder, not a real ID. "
"You MUST call calendar(action='list_events', time_min='today') NOW to get the actual event ID. "
"Do NOT explain - just call list_events immediately, then extract the ID from the response and call update_event again."
)
# Check if user wants to cancel (via status parameter in kwargs)
status = kwargs.get("status", "").lower() if kwargs else ""
if status == "cancelled":
# To cancel a meeting, we delete it
return await self._delete_event(service, calendar_id, event_id)
# For update_event, start_time and end_time are the new times
return await self._update_event(
service,
calendar_id,
event_id,
title,
start_time, # New start time
end_time, # New end time
description,
location,
attendees,
)
elif action == "delete_event":
if not event_id:
return (
"STOP: event_id is required for delete_event. "
"DO NOT EXPLAIN. IMMEDIATELY call calendar(action='list_events', time_min='today') NOW. "
"Then extract event IDs from the response and call delete_events (plural) with those IDs."
)
# Validate event_id is not a placeholder
if any(placeholder in event_id.lower() for placeholder in [
"get event", "from calendar", "list_events", "<id", "placeholder",
"[get", "event id", "id of", "id from", "[id from", "previous call", "list events call"
]) or len(event_id) < 15:
return (
f"STOP: Invalid event_id '{event_id}' - this is a placeholder, not a real ID. "
f"DO NOT EXPLAIN. IMMEDIATELY call calendar(action='list_events', time_min='today') NOW. "
f"Then extract event IDs from the response and call delete_events (plural) with those IDs. "
f"Event IDs are long alphanumeric strings (20+ characters, no spaces) from the list_events response."
)
return await self._delete_event(service, calendar_id, event_id)
elif action == "delete_events":
if not event_ids:
return (
"STOP: event_ids (array) is required for delete_events. "
"DO NOT EXPLAIN. IMMEDIATELY call calendar(action='list_events', time_min='today') NOW. "
"Then extract ALL event IDs from the response (they appear after '[ID: ' or in the 'Event IDs:' line) "
"and call delete_events with those IDs."
)
return await self._delete_events(service, calendar_id, event_ids)
elif action == "check_availability":
if not start_time:
return "Error: start_time is required for check_availability"
return await self._check_availability(service, calendar_id, start_time, end_time)
else:
return f"Error: Unknown action '{action}'. Use 'list_events', 'create_event', or 'check_availability'"
return f"Error: Unknown action '{action}'. Use 'list_events', 'create_event', 'delete_event', 'delete_events', 'update_event', or 'check_availability'"
except HttpError as e:
return f"Error accessing Google Calendar API: {e}"
except Exception as e:
@ -558,7 +691,20 @@ class CalendarTool(Tool):
def _list():
now = datetime.utcnow().isoformat() + "Z"
time_min_str = time_min if time_min else now
# Handle "now", "today", or None - convert to ISO format
if not time_min or time_min.lower() in ["now", "today"]:
time_min_str = now
elif time_min.endswith("Z") or "+" in time_min or "-" in time_min[-6:]:
# Already in ISO format
time_min_str = time_min
else:
# Try to parse as natural language and convert to ISO
try:
parsed_dt = self._parse_time(time_min)
time_min_str = parsed_dt.isoformat()
except Exception:
# If parsing fails, use current time
time_min_str = now
events_result = (
service.events()
@ -578,11 +724,27 @@ class CalendarTool(Tool):
return "No upcoming events found."
result = [f"Found {len(events)} upcoming event(s):\n"]
for event in events:
event_ids = []
for idx, event in enumerate(events, 1):
start = event["start"].get("dateTime", event["start"].get("date"))
title = event.get("summary", "No title")
result.append(f"- {title} ({start})")
event_id = event.get("id", "")
event_ids.append(event_id)
# Add position indicator - "last" means the one with the latest time (usually the last in list)
position_note = ""
if idx == len(events):
position_note = " (LAST - latest time)"
elif idx == 1:
position_note = " (FIRST - earliest time)"
# Format: include numbered list with position indicators
result.append(f"{idx}. {title} ({start}) [ID: {event_id}]{position_note}")
# Also include a summary line with all IDs for easy extraction
result.append(f"\nEvent IDs: {', '.join(event_ids)}")
result.append(f"\nNote: 'last meeting' means the meeting with the latest time (usually #{len(events)} in the list above).")
return "\n".join(result)
return await asyncio.to_thread(_list)
@ -608,9 +770,13 @@ class CalendarTool(Tool):
else:
end_dt = start_dt + timedelta(hours=1) # Default 1 hour
# Get configured timezone
config = self.config
tz_str = getattr(config, 'timezone', 'UTC') or 'UTC'
# Validate that start time is not in the past
from datetime import timezone
now = datetime.now(timezone.utc)
tz = self._get_timezone()
now = datetime.now(tz)
if start_dt < now:
raise ValueError(
f"Cannot schedule event in the past. Start time ({start_dt}) is before current time ({now}). "
@ -625,22 +791,15 @@ class CalendarTool(Tool):
f"Parsed from start_time='{start_time}', end_time='{end_time}'"
)
# Ensure datetimes are timezone-aware (UTC)
from datetime import timezone
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=timezone.utc)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=timezone.utc)
event = {
"summary": title,
"start": {
"dateTime": start_dt.isoformat(),
"timeZone": "UTC",
"timeZone": tz_str,
},
"end": {
"dateTime": end_dt.isoformat(),
"timeZone": "UTC",
"timeZone": tz_str,
},
}
@ -711,3 +870,218 @@ class CalendarTool(Tool):
return await asyncio.to_thread(_check)
async def _delete_event(
self, service: Any, calendar_id: str, event_id: str
) -> 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)

View File

@ -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:**