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
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |
| import random | ||
| import string | ||
| from collections.abc import Awaitable, Callable | ||
| from datetime import date | ||
| from datetime import date, datetime | ||
| from time import monotonic | ||
| from typing import Any, cast | ||
|
|
||
|
|
@@ -14,6 +14,8 @@ | |
| from nc_py_api import AsyncNextcloudApp | ||
| from nc_py_api.ex_app import persistent_storage | ||
|
|
||
| import pytz | ||
|
|
||
| from ex_app.lib.all_tools.nextcloud_links import get_absolute_base_url | ||
| from ex_app.lib.all_tools.skills import list_skills_metadata | ||
| from ex_app.lib.graph import AgentState, get_graph | ||
|
|
@@ -112,6 +114,15 @@ async def react( | |
| model.bind_nextcloud(nc) | ||
| model.multimodal = multimodal | ||
|
|
||
| tz_name = user_tz = None | ||
| try: | ||
| user = (await nc.ocs("GET", "/ocs/v2.php/cloud/user")) or {} | ||
| tz_name = user.get("timezone") or None | ||
| if tz_name: | ||
| user_tz = pytz.timezone(tz_name) | ||
| except Exception: | ||
| tz_name = user_tz = None | ||
|
|
||
| safe_tools, dangerous_tools = await get_tools(nc) | ||
|
|
||
| tools = dangerous_tools + safe_tools | ||
|
|
@@ -130,12 +141,18 @@ async def call_model( | |
| state: AgentState, | ||
| config: RunnableConfig, | ||
| ): | ||
| current_date = date.today().strftime("%Y-%m-%d") | ||
| if user_tz is not None: | ||
| now = datetime.now(user_tz) | ||
| current_date = now.strftime("%Y-%m-%d") | ||
| current_time = f" The current time is {now.strftime('%H:%M')} in the user's timezone {tz_name}." | ||
| else: | ||
| current_date = date.today().strftime("%Y-%m-%d") | ||
| current_time = "" | ||
|
|
||
| system_prompt_text = """ | ||
| You are a helpful AI assistant with access to tools, please respond to the user's query to the best of your ability, using the provided tools if necessary. If no tool is needed to provide a correct answer, do not use one. If you used a tool, you still need to convey its output to the user. | ||
| Use the same language for your answers as the user used in their message. | ||
| Today is {CURRENT_DATE}. | ||
| Today is {CURRENT_DATE}.{CURRENT_TIME} | ||
|
Comment on lines
-138
to
+155
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. wdyt of passing just the date and the timezone since after some time in the chat session or picking it up again, the time would be off, so would the date but that wouldn't affect that many sessions, only the ones that span across days.
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. Not a concern, substituted inside call_model on every LLM round (and every turn of a resumed session), recomputes the current date/time anew
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. ah you're right, damn, but that's all the more reason to not include the time in the system prompt, it would bust the cache every second. (for later reference: https://github.com/nextcloud/context_agent/blob/ee5df89e3dcb71f05d2665647adf27d023f7fa96/ex_app/lib/agent.py #L212-L218)
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. Did some digging: prefix caching needs byte identical prefixes, and dynamic timestamps in system prompts are a known cache killer in production, e.g. openclaw hit exactly this and paid 10x (openclaw#19534), the date itself is byte stable within a day though, so only the current time breaks the cache on every call. Claude.ai itself uses "current date in the system prompt, injected once per conversation" (Claude system prompt docs, Claude 4 prompt analysis) plus an official time MCP reference server for precise time (modelcontextprotocol/servers/src/time), and the general rule is stable content before variable content with cache points after the stable part (AWS Bedrock prompt caching).
So i'd follow that pattern: keep the date in the prompt, drop the time, add a small
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. sorry I should've added more context, yeah the same cache. yep, that should do the trick. |
||
| Intuit the language the user is using (there is no tool for this, you will need to guess). Reply in the language intuited. Do not output the language you intuited. | ||
| Only use tools if you cannot answer the user without them. | ||
| If you get a link as a tool output, always add the link to your response. | ||
|
|
@@ -152,7 +169,11 @@ async def call_model( | |
| system_prompt_text += "Use the find_person_in_contacts tool to find a person's email address and location.\n" | ||
| if tool_enabled("find_person_in_users"): | ||
| system_prompt_text += "Use the find_person_in_users tool to find a person's userId and user details.\n" | ||
| if tool_enabled("find_details_of_current_user"): | ||
| if tz_name is not None: | ||
| system_prompt_text += ( | ||
| f"The user's timezone is {tz_name}. Use it for the timezone parameter of calendar/task tools.\n" | ||
| ) | ||
|
Comment on lines
+172
to
+175
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. might be good to also move this to the date replacement part so date and timezone appear at the same place. |
||
| elif tool_enabled("find_details_of_current_user"): | ||
| system_prompt_text += "Use the find_details_of_current_user tool to find the current user's location and timezone.\n" | ||
| if tool_enabled("list_mails"): | ||
| system_prompt_text += "Always check for the mail account id before requesting a folder list.\n" | ||
|
|
@@ -189,7 +210,7 @@ async def call_model( | |
|
|
||
| # this is similar to customizing the create_react_agent with state_modifier, but is a lot more flexible | ||
| system_prompt = SystemMessage( | ||
| system_prompt_text.replace("{CURRENT_DATE}", current_date) | ||
| system_prompt_text.replace("{CURRENT_DATE}", current_date).replace("{CURRENT_TIME}", current_time) | ||
| ) | ||
|
|
||
| response = await bound_model.ainvoke([system_prompt] + state["messages"], config) | ||
|
|
||
|
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,7 +38,7 @@ 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") | ||
|
|
@@ -51,25 +54,31 @@ def schedule_event_sync(calendar_name: str, title: str, description: str, start_ | |
| 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) | ||
| # Set timezone (defaults to the user's profile timezone) | ||
| timezone = timezone or user_timezone(ncSync) | ||
| tz = pytz.timezone(get_timezone(timezone)) | ||
| start_datetime = tz.localize(start_datetime) | ||
| end_datetime = tz.localize(end_datetime) | ||
|
|
||
| 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
+68
to
+69
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 +98,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 +123,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 +207,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 +229,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 +247,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 +352,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, timezone: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None): | ||
| principal = ncSync.cal.principal() | ||
| calendars = principal.calendars() | ||
| calendar = {cal.name: cal for cal in calendars}[calendar_name] | ||
|
|
@@ -373,9 +381,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 +397,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, timezone: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, due_date: Optional[str] = None, due_time: Optional[str] = None, priority: Optional[int] = None): | ||
|
Comment on lines
-393
to
+400
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. would be good to revert the change of timezone's position in the arg list since it's not required now + the function doc has a different order |
||
| """ | ||
| Update an existing task | ||
| :param calendar_name: The name of the calendar containing the task (obtainable via list_calendars) | ||
|
|
@@ -399,11 +406,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 timezone: Timezone (e.g., 'America/New_York'). Defaults to the user's profile timezone. Must be a valid IANA timezone name. | ||
| :param priority: Priority from 0 (undefined) to 9 (lowest), where 1 is highest priority | ||
| :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, timezone, title, description, due_date, due_time, priority) | ||
|
Comment on lines
-406
to
+413
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. this and the function's formal args too for consistency |
||
|
|
||
| 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