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:
parent
bc5f169bc8
commit
bc53dc6535
@ -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)
|
||||
|
||||
|
||||
@ -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:**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user