arcade-mcp/toolkits/google/arcade_google/tools/calendar.py
Renato Byrro ac0f5aa10c
Search Google Drive documents and retrieve contents (#265)
This tool will be useful in scenarios akin to RAG, where someone wants
to ask questions or request the production of a summary, for instance,
about a bunch of documents related to a particular topic. Currently, to
fulfill such requests, the LLM needs to first `list_documents`, then
`get_document_by_id` for each document.

We also implement a utility functions to return documents in Markdown
and HTML, since the Drive API JSON is verbose and would waste too many
tokens unnecessarily.

Limitations: the Markdown/HTML utilities do not handle table of contents
(which I think aren't really useful here), headers, footers, or
footnotes.

---
This PR deprecates `list_documents` and implements `search_documents`,
apart from `search_and_retrieve_documents`). This configuration makes it
easier for LLMs to understand when to call each tool.

Both tools had their interfaces refactored to remove Google API-specific
arguments that were confusing LLMs sometimes, such as "corpora" and
"support_all_drives". It now accepts arguments that better relate to
expected user requests.

---------

Co-authored-by: Eric Gustin <eric@arcade.dev>
2025-03-07 18:42:12 -03:00

358 lines
12 KiB
Python

from datetime import datetime, timedelta
from typing import Annotated, Any
from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import Google
from arcade.sdk.errors import RetryableToolError
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from arcade_google.models import EventVisibility, SendUpdatesOptions
from arcade_google.utils import parse_datetime
@tool(
requires_auth=Google(
scopes=[
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
],
)
)
async def create_event(
context: ToolContext,
summary: Annotated[str, "The title of the event"],
start_datetime: Annotated[
str,
"The datetime when the event starts in ISO 8601 format, e.g., '2024-12-31T15:30:00'.",
],
end_datetime: Annotated[
str,
"The datetime when the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.",
],
calendar_id: Annotated[
str, "The ID of the calendar to create the event in, usually 'primary'."
] = "primary",
description: Annotated[str | None, "The description of the event"] = None,
location: Annotated[str | None, "The location of the event"] = None,
visibility: Annotated[EventVisibility, "The visibility of the event"] = EventVisibility.DEFAULT,
attendee_emails: Annotated[
list[str] | None,
"The list of attendee emails. Must be valid email addresses e.g., username@domain.com.",
] = None,
) -> Annotated[dict, "A dictionary containing the created event details"]:
"""Create a new event/meeting/sync/meetup in the specified calendar."""
service = build(
"calendar",
"v3",
credentials=Credentials(
context.authorization.token
if context.authorization and context.authorization.token
else ""
),
)
# Get the calendar's time zone
calendar = service.calendars().get(calendarId=calendar_id).execute()
time_zone = calendar["timeZone"]
# Parse datetime strings
start_dt = parse_datetime(start_datetime, time_zone)
end_dt = parse_datetime(end_datetime, time_zone)
event: dict[str, Any] = {
"summary": summary,
"description": description,
"location": location,
"start": {"dateTime": start_dt.isoformat(), "timeZone": time_zone},
"end": {"dateTime": end_dt.isoformat(), "timeZone": time_zone},
"visibility": visibility.value,
}
if attendee_emails:
event["attendees"] = [{"email": email} for email in attendee_emails]
created_event = service.events().insert(calendarId=calendar_id, body=event).execute()
return {"event": created_event}
@tool(
requires_auth=Google(
scopes=[
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
],
)
)
async def list_events(
context: ToolContext,
min_end_datetime: Annotated[
str,
"Filter by events that end on or after this datetime in ISO 8601 format, "
"e.g., '2024-09-15T09:00:00'.",
],
max_start_datetime: Annotated[
str,
"Filter by events that start before this datetime in ISO 8601 format, "
"e.g., '2024-09-16T17:00:00'.",
],
calendar_id: Annotated[str, "The ID of the calendar to list events from"] = "primary",
max_results: Annotated[int, "The maximum number of events to return"] = 10,
) -> Annotated[dict, "A dictionary containing the list of events"]:
"""
List events from the specified calendar within the given datetime range.
min_end_datetime serves as the lower bound (exclusive) for an event's end time.
max_start_datetime serves as the upper bound (exclusive) for an event's start time.
For example:
If min_end_datetime is set to 2024-09-15T09:00:00 and max_start_datetime
is set to 2024-09-16T17:00:00, the function will return events that:
1. End after 09:00 on September 15, 2024 (exclusive)
2. Start before 17:00 on September 16, 2024 (exclusive)
This means an event starting at 08:00 on September 15 and
ending at 10:00 on September 15 would be included, but an
event starting at 17:00 on September 16 would not be included.
"""
service = build(
"calendar",
"v3",
credentials=Credentials(
context.authorization.token
if context.authorization and context.authorization.token
else ""
),
)
# Get the calendar's time zone
calendar = service.calendars().get(calendarId=calendar_id).execute()
time_zone = calendar["timeZone"]
# Parse datetime strings
min_end_dt = parse_datetime(min_end_datetime, time_zone)
max_start_dt = parse_datetime(max_start_datetime, time_zone)
if min_end_dt > max_start_dt:
min_end_dt, max_start_dt = max_start_dt, min_end_dt
events_result = (
service.events()
.list(
calendarId=calendar_id,
timeMin=min_end_dt.isoformat(),
timeMax=max_start_dt.isoformat(),
maxResults=max_results,
singleEvents=True,
orderBy="startTime",
)
.execute()
)
items_keys = [
"attachments",
"attendees",
"creator",
"description",
"end",
"eventType",
"htmlLink",
"id",
"location",
"organizer",
"start",
"summary",
"visibility",
]
events = [
{key: event[key] for key in items_keys if key in event}
for event in events_result.get("items", [])
]
return {"events_count": len(events), "events": events}
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/calendar.events"],
)
)
async def update_event(
context: ToolContext,
event_id: Annotated[str, "The ID of the event to update"],
updated_start_datetime: Annotated[
str | None,
"The updated datetime that the event starts in ISO 8601 format, "
"e.g., '2024-12-31T15:30:00'.",
] = None,
updated_end_datetime: Annotated[
str | None,
"The updated datetime that the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.",
] = None,
updated_calendar_id: Annotated[
str | None, "The updated ID of the calendar containing the event."
] = None,
updated_summary: Annotated[str | None, "The updated title of the event"] = None,
updated_description: Annotated[str | None, "The updated description of the event"] = None,
updated_location: Annotated[str | None, "The updated location of the event"] = None,
updated_visibility: Annotated[EventVisibility | None, "The visibility of the event"] = None,
attendee_emails_to_add: Annotated[
list[str] | None,
"The list of attendee emails to add. Must be valid email addresses "
"e.g., username@domain.com.",
] = None,
attendee_emails_to_remove: Annotated[
list[str] | None,
"The list of attendee emails to remove. Must be valid email addresses "
"e.g., username@domain.com.",
] = None,
send_updates: Annotated[
SendUpdatesOptions,
"Should attendees be notified of the update? (none, all, external_only)",
] = SendUpdatesOptions.ALL,
) -> Annotated[
str,
"A string containing the updated event details, including the event ID, update timestamp, "
"and a link to view the updated event.",
]:
"""
Update an existing event in the specified calendar with the provided details.
Only the provided fields will be updated; others will remain unchanged.
`updated_start_datetime` and `updated_end_datetime` are
independent and can be provided separately.
"""
service = build(
"calendar",
"v3",
credentials=Credentials(
context.authorization.token
if context.authorization and context.authorization.token
else ""
),
)
calendar = service.calendars().get(calendarId="primary").execute()
time_zone = calendar["timeZone"]
try:
event = service.events().get(calendarId="primary", eventId=event_id).execute()
except HttpError:
valid_events_with_id = (
service.events()
.list(
calendarId="primary",
timeMin=(datetime.now() - timedelta(days=2)).isoformat(),
timeMax=(datetime.now() + timedelta(days=365)).isoformat(),
maxResults=50,
singleEvents=True,
orderBy="startTime",
)
.execute()
)
raise RetryableToolError(
f"Event with ID {event_id} not found.",
additional_prompt_content=(
f"Here is a list of valid events. The event_id parameter must match one of these: "
f"{valid_events_with_id}"
),
retry_after_ms=1000,
developer_message=(
f"Event with ID {event_id} not found. Please try again with a valid event ID."
),
)
update_fields = {
"start": {"dateTime": updated_start_datetime, "timeZone": time_zone}
if updated_start_datetime
else None,
"end": {"dateTime": updated_end_datetime, "timeZone": time_zone}
if updated_end_datetime
else None,
"calendarId": updated_calendar_id,
"sendUpdates": send_updates.value if send_updates else None,
"summary": updated_summary,
"description": updated_description,
"location": updated_location,
"visibility": updated_visibility.value if updated_visibility else None,
}
event.update({k: v for k, v in update_fields.items() if v is not None})
if attendee_emails_to_remove:
event["attendees"] = [
attendee
for attendee in event.get("attendees", [])
if attendee.get("email", "").lower()
not in [email.lower() for email in attendee_emails_to_remove]
]
if attendee_emails_to_add:
existing_emails = {
attendee.get("email", "").lower() for attendee in event.get("attendees", [])
}
new_attendees = [
{"email": email}
for email in attendee_emails_to_add
if email.lower() not in existing_emails
]
event["attendees"] = event.get("attendees", []) + new_attendees
updated_event = (
service.events()
.update(
calendarId="primary",
eventId=event_id,
sendUpdates=send_updates.value,
body=event,
)
.execute()
)
return (
f"Event with ID {event_id} successfully updated at {updated_event['updated']}. "
f"View updated event at {updated_event['htmlLink']}"
)
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/calendar.events"],
)
)
async def delete_event(
context: ToolContext,
event_id: Annotated[str, "The ID of the event to delete"],
calendar_id: Annotated[str, "The ID of the calendar containing the event"] = "primary",
send_updates: Annotated[
SendUpdatesOptions, "Specifies which attendees to notify about the deletion"
] = SendUpdatesOptions.ALL,
) -> Annotated[str, "A string containing the deletion confirmation message"]:
"""Delete an event from Google Calendar."""
service = build(
"calendar",
"v3",
credentials=Credentials(
context.authorization.token
if context.authorization and context.authorization.token
else ""
),
)
service.events().delete(
calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates.value
).execute()
notification_message = ""
if send_updates == SendUpdatesOptions.ALL:
notification_message = "Notifications were sent to all attendees."
elif send_updates == SendUpdatesOptions.EXTERNAL_ONLY:
notification_message = "Notifications were sent to external attendees only."
elif send_updates == SendUpdatesOptions.NONE:
notification_message = "No notifications were sent to attendees."
return (
f"Event with ID '{event_id}' successfully deleted from calendar '{calendar_id}'. "
f"{notification_message}"
)