Repository navigation
fix(tools): require timezone on time tools, inject user timezone into the prompt #257
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b449c3b
5b0730a
ee5df89
1e1445a
3b32b00
c535fc4
722092f
35aa05a
e88e3e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
kyteinsky marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,18 +2,21 @@ | |
| # SPDX-License-Identifier: AGPL-3.0-or-later | ||
| import asyncio | ||
| import time | ||
| import uuid | ||
| from datetime import datetime, timezone, timedelta | ||
| from typing import Optional | ||
|
|
||
| from niquests import ConnectionError, Timeout | ||
| import pytz | ||
| from ics import Calendar, Event, Attendee, Organizer, Todo | ||
| from icalendar import Event as ICalendarEvent, vCalAddress, vText | ||
| from ics import Calendar, Todo | ||
| from langchain_core.tools import tool | ||
| from nc_py_api import AsyncNextcloudApp, NextcloudApp | ||
| import xml.etree.ElementTree as ET | ||
| import vobject | ||
|
|
||
| from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool | ||
| from ex_app.lib.all_tools.lib.tz import get_timezone, user_timezone | ||
| from ex_app.lib.all_tools.lib.freebusy_finder import find_available_slots, round_to_nearest_half_hour | ||
|
|
||
|
|
||
|
|
@@ -35,41 +38,48 @@ async def list_calendars(): | |
| """ | ||
| return await asyncio.to_thread(list_calendars_sync) | ||
|
|
||
| def schedule_event_sync(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str]): | ||
| def schedule_event_sync(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, location: Optional[str] = None, timezone: Optional[str] = None): | ||
| # Parse date and times | ||
| start_date = datetime.strptime(start_date, "%Y-%m-%d") | ||
| end_date = datetime.strptime(end_date, "%Y-%m-%d") | ||
|
|
||
| # Set timezone (defaults to the user's profile timezone); validated even for | ||
| # all-day events so a bad value errors instead of being silently ignored | ||
| timezone = timezone or user_timezone(ncSync) | ||
| tz = pytz.timezone(get_timezone(timezone)) | ||
|
|
||
| # Combine date and time | ||
| if start_time and end_time: | ||
| start_time = datetime.strptime(start_time, "%I:%M %p").time() | ||
| end_time = datetime.strptime(end_time, "%I:%M %p").time() | ||
|
|
||
| start_datetime = datetime.combine(start_date, start_time) | ||
| end_datetime = datetime.combine(end_date, end_time) | ||
| # icalendar keeps the timezone as a TZID on datetime values | ||
| start_datetime = tz.localize(datetime.combine(start_date, start_time)) | ||
| end_datetime = tz.localize(datetime.combine(end_date, end_time)) | ||
| else: | ||
| start_datetime = start_date | ||
| end_datetime = end_date | ||
|
|
||
| # Set timezone | ||
| if timezone != None: | ||
| tz = pytz.timezone(timezone) | ||
| start_datetime = tz.localize(start_datetime) | ||
| end_datetime = tz.localize(end_datetime) | ||
| # All-day events are DATE values; DTEND is exclusive, so the day after | ||
| start_datetime = start_date.date() | ||
| end_datetime = (end_date + timedelta(days=1)).date() | ||
|
|
||
| description_with_ai_note = f"{description}\n\n---\n\nThis event was scheduled by Nextcloud AI Assistant." | ||
|
|
||
| # Create event | ||
| c = Calendar() | ||
| e = Event() | ||
| e.name = title | ||
| e.begin = start_datetime | ||
| e.end = end_datetime | ||
| e.description = description_with_ai_note | ||
| e.location = location | ||
| if attendees is not None: | ||
| for attendee in attendees: | ||
| e.add_attendee(Attendee(common_name=attendee, email=attendee, partstat='NEEDS-ACTION', role='REQ-PARTICIPANT', cutype='INDIVIDUAL')) | ||
| # Create event (icalendar keeps the event's timezone as a TZID; the ics library would serialize it to UTC) | ||
| e = ICalendarEvent() | ||
| e.add('summary', title) | ||
| e.add('dtstart', start_datetime) | ||
| e.add('dtend', end_datetime) | ||
|
Comment on lines
+69
to
+70
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. haven't investigated how the calendar API works but there was an issue with scheduling all day events, i.e. when start and end dates were passed "None" or empty strings by the agent.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Date only input serializes as DTSTART;VALUE=DATE, so all day events work, and empty time strings fall into the all day branch as well.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. seems to not work on my end, can you check if the following info was same on your end? and the resulting event is as below (note the
I manually created an all day event from the UI, it's the above one:
the corresponding request body:
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. repro'd with your exact args, thanks slightly worse than your screenshot even, the all-day branch keeps the dtend +1 day is missing too. also correcting myself: my roundtrip on friday only ran the timed case, the "date-only serializes as VALUE=DATE" bit was untested. |
||
| e.add('description', description_with_ai_note) | ||
| if location: | ||
| e.add('location', location) | ||
| e.add('uid', str(uuid.uuid4()) + '@context-agent') | ||
| e.add('dtstamp', datetime.now(pytz.UTC)) | ||
| for attendee in attendees or []: | ||
| a = vCalAddress('mailto:' + attendee) | ||
| a.params['CN'] = vText(attendee) | ||
| a.params['PARTSTAT'] = vText('NEEDS-ACTION') | ||
| a.params['ROLE'] = vText('REQ-PARTICIPANT') | ||
| a.params['CUTYPE'] = vText('INDIVIDUAL') | ||
| e.add('attendee', a) | ||
|
|
||
| # let's check who we are... | ||
| i = 0 | ||
|
|
@@ -89,19 +99,20 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_ | |
| raise Exception('Error fetching current user information') | ||
|
|
||
| # ...and set the organizer | ||
| e.organizer = Organizer(common_name=json['displayname'], email=json['email']) | ||
| organizer = vCalAddress('mailto:' + (json['email'] or '')) | ||
| organizer.params['CN'] = vText(json['displayname']) | ||
| e.add('organizer', organizer) | ||
|
|
||
| # Add event to calendar | ||
| c.events.add(e) | ||
|
|
||
| principal = ncSync.cal.principal() | ||
| calendars = principal.calendars() | ||
| calendar = {cal.name: cal for cal in calendars}[calendar_name] | ||
| calendar.add_event(str(c)) | ||
| calendar.add_event(e.to_ical().decode()) | ||
|
|
||
| @tool | ||
| @dangerous_tool | ||
| async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str]): | ||
| async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, location: Optional[str] = None, timezone: Optional[str] = None): | ||
| """ | ||
| Crete a new event or meeting in a calendar. Omit start_time and end_time parameters to create an all-day event. | ||
| :param calendar_name: The name of the calendar to add the event to | ||
|
|
@@ -113,7 +124,7 @@ async def schedule_event(calendar_name: str, title: str, description: str, start | |
| :param start_time: the start time in the following form: HH:MM AM/PM e.g. '3:00 PM' | ||
| :param end_time: the start time in the following form: HH:MM AM/PM e.g. '4:00 PM' | ||
| :param location: The location of the event | ||
| :param timezone: Timezone (e.g., 'America/New_York'). | ||
| :param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. | ||
| :return: bool | ||
| """ | ||
| await asyncio.to_thread(schedule_event_sync, calendar_name, title, description, start_date, end_date, attendees, start_time, end_time, location, timezone) | ||
|
|
@@ -197,8 +208,8 @@ async def find_free_time_slot_in_calendar(participants: list[str], slot_duration | |
| available_slots = await asyncio.to_thread(find_free_time_slot_in_calendar_sync, participants, slot_duration, start_time, end_time) | ||
| return available_slots | ||
|
|
||
| def add_task_sync(calendar_name: str, title: str, description: str, due_date: Optional[str], | ||
| due_time: Optional[str], timezone: Optional[str]): | ||
| def add_task_sync(calendar_name: str, title: str, description: str, due_date: Optional[str] = None, | ||
| due_time: Optional[str] = None, timezone: Optional[str] = None): | ||
| description_with_ai_note = f"{description}\n\n---\n\nThis task was scheduled by Nextcloud AI Assistant." | ||
|
|
||
| # Create task | ||
|
|
@@ -219,10 +230,9 @@ def add_task_sync(calendar_name: str, title: str, description: str, due_date: Op | |
| else: | ||
| due_datetime = due_date | ||
|
|
||
| # Set timezone | ||
| if timezone != None: | ||
| tz = pytz.timezone(timezone) | ||
| due_datetime = tz.localize(due_datetime) | ||
| # Set timezone (defaults to the user's profile timezone) | ||
| timezone = timezone or user_timezone(ncSync) | ||
| due_datetime = pytz.timezone(get_timezone(timezone)).localize(due_datetime) | ||
|
|
||
| t.due = due_datetime | ||
|
|
||
|
|
@@ -238,18 +248,17 @@ def add_task_sync(calendar_name: str, title: str, description: str, due_date: Op | |
|
|
||
| @tool | ||
| @dangerous_tool | ||
| async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str], due_time: Optional[str], timezone: Optional[str]): | ||
| async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone: Optional[str] = None): | ||
| """ | ||
| Crete a new task in a calendar. | ||
| :param calendar_name: The name of the calendar to add the task to | ||
| :param title: The title of the task | ||
| :param description: The description of the task | ||
| :param due_date: the due date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01' | ||
| :param due_time: the due time in the following form: HH:MM AM/PM e.g. '3:00 PM' | ||
| :param timezone: Timezone (e.g., 'America/New_York'). Is required if there is a specified due date. | ||
| :param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. | ||
| :return: bool | ||
| """ | ||
|
|
||
| await asyncio.to_thread(add_task_sync, calendar_name, title, description, due_date, due_time, timezone) | ||
|
|
||
| return True | ||
|
|
@@ -344,7 +353,7 @@ async def complete_task(calendar_name: str, task_uid: str): | |
| """ | ||
| return await asyncio.to_thread(complete_task_sync, calendar_name, task_uid) | ||
|
|
||
| def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone_str: Optional[str] = None, priority: Optional[int] = None): | ||
| def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None, timezone: Optional[str] = None): | ||
| principal = ncSync.cal.principal() | ||
| calendars = principal.calendars() | ||
| calendar = {cal.name: cal for cal in calendars}[calendar_name] | ||
|
|
@@ -373,9 +382,8 @@ def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = N | |
| else: | ||
| due_datetime = parsed_date | ||
|
|
||
| if timezone_str: | ||
| tz = pytz.timezone(timezone_str) | ||
| due_datetime = tz.localize(due_datetime) | ||
| timezone = timezone or user_timezone(ncSync) | ||
| due_datetime = pytz.timezone(get_timezone(timezone)).localize(due_datetime) | ||
|
|
||
| ics_todo.due = due_datetime | ||
|
|
||
|
|
@@ -390,7 +398,7 @@ def update_task_sync(calendar_name: str, task_uid: str, title: Optional[str] = N | |
|
|
||
| @tool | ||
| @dangerous_tool | ||
| async def update_task(calendar_name: str, task_uid: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, timezone: Optional[str] = None, priority: Optional[int] = None): | ||
| async def update_task(calendar_name: str, task_uid: str, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None, timezone: Optional[str] = None): | ||
| """ | ||
| Update an existing task | ||
| :param calendar_name: The name of the calendar containing the task (obtainable via list_calendars) | ||
|
|
@@ -399,11 +407,11 @@ async def update_task(calendar_name: str, task_uid: str, title: Optional[str] = | |
| :param description: New description for the task | ||
| :param due_date: New due date in the form: YYYY-MM-DD e.g. '2024-12-01' | ||
| :param due_time: New due time in the form: HH:MM AM/PM e.g. '3:00 PM' | ||
| :param timezone: Timezone (e.g., 'America/New_York') | ||
| :param priority: Priority from 0 (undefined) to 9 (lowest), where 1 is highest priority | ||
| :param timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. | ||
| :return: bool indicating success | ||
| """ | ||
| return await asyncio.to_thread(update_task_sync, calendar_name, task_uid, title, description, due_date, due_time, timezone, priority) | ||
| return await asyncio.to_thread(update_task_sync, calendar_name, task_uid, title, description, due_date, due_time, priority, timezone) | ||
|
|
||
| def delete_task_sync(calendar_name: str, task_uid: str): | ||
| principal = ncSync.cal.principal() | ||
|
|
||



There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks for pointing out that this code is re-run every time
maybe we can save some network calls here by caching this value per user?
and since it can change, with a timed cache perhaps:
context_agent/ex_app/lib/all_tools/lib/decorator.py
Line 17 in 33d170f