diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index dfcc041..c70ac0b 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -139,10 +139,10 @@ jobs: run: | ./occ app_api:daemon:register --net host manual_install "Manual Install" manual-install http localhost http://localhost:8080 - - name: Setup python 3.10 + - name: Setup python 3.11 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 #v6.2.0 with: - python-version: '3.10' + python-version: '3.11' - name: Install llm2 app working-directory: llm2 diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index 839025d..29853d0 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -8,7 +8,7 @@ from langchain_core.messages import ToolMessage, SystemMessage, AIMessage, HumanMessage from langchain_core.runnables import RunnableConfig, RunnableLambda -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from nc_py_api.ex_app import persistent_storage from ex_app.lib.signature import verify_signature @@ -93,7 +93,7 @@ def export_conversation(checkpointer): conversation_token = add_signature(serialized_state.decode('utf-8'), key) return conversation_token -async def react(task, nc: Nextcloud): +async def react(task, nc: AsyncNextcloudApp): safe_tools, dangerous_tools = await get_tools(nc) model.bind_nextcloud(nc) diff --git a/ex_app/lib/all_tools/audio2text.py b/ex_app/lib/all_tools/audio2text.py index 395d747..6a9b0da 100644 --- a/ex_app/lib/all_tools/audio2text.py +++ b/ex_app/lib/all_tools/audio2text.py @@ -1,28 +1,28 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.files import get_file_id_from_file_url from ex_app.lib.all_tools.lib.task_processing import run_task from ex_app.lib.all_tools.lib.decorator import safe_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def transcribe_file(file_url: str) -> str: + async def transcribe_file(file_url: str) -> str: """ Transcribe a media file stored inside Nextcloud - :param file_url: The file URL to the media file in nextcloud (The user can input this using the smart picker for example) + :param file_url: The file URL to the media file in nextcloud (e.g. https://host/index.php/f/1234 - The user can input this using the smart picker for example) :return: the transcription result """ task_input = { 'input': get_file_id_from_file_url(file_url), } - task_output = run_task(nc, "core:audio2text", task_input).output + task_output = (await run_task(nc, "core:audio2text", task_input)).output return task_output['output'] return [ @@ -32,6 +32,6 @@ def transcribe_file(file_url: str) -> str: def get_category_name(): return "Audio transcription" -def is_available(nc: Nextcloud): - tasktypes = nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes')['types'].keys() +async def is_available(nc: AsyncNextcloudApp): + tasktypes = (await nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes'))['types'].keys() return 'core:audio2text' in tasktypes \ No newline at end of file diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index 301997e..3dc9775 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -1,14 +1,15 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import asyncio +import time from datetime import datetime, timezone, timedelta -from time import sleep from typing import Optional from niquests import ConnectionError, Timeout import pytz from ics import Calendar, Event, Attendee, Organizer, Todo from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp, NextcloudApp from nc_py_api.ex_app import LogLvl import xml.etree.ElementTree as ET import vobject @@ -18,37 +19,25 @@ from ex_app.lib.logger import log -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): + ncSync = NextcloudApp() + ncSync.set_user(await nc.user) - @tool - @safe_tool - def list_calendars(): - """ - List all existing calendars by name - :return: - """ - principal = nc.cal.principal() + def list_calendars_sync(): + principal = ncSync.cal.principal() calendars = principal.calendars() return ", ".join([cal.name for cal in calendars]) @tool - @dangerous_tool - 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]): + @safe_tool + async def list_calendars(): """ - 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 - :param title: The title of the event - :param description: The description of the event - :param start_date: the start date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01' - :param end_date: the end date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01' - :param attendees: the list of attendees to add to the event (as email addresses) - :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'). - :return: bool + List all existing calendars by name + :return: a comma-separated list of calendar names """ + 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]): # Parse date and times start_date = datetime.strptime(start_date, "%Y-%m-%d") end_date = datetime.strptime(end_date, "%Y-%m-%d") @@ -88,15 +77,15 @@ def schedule_event(calendar_name: str, title: str, description: str, start_date: i = 0 while i < 20: try: - json = nc.ocs('GET', '/ocs/v2.php/cloud/user') + json = ncSync.ocs('GET', '/ocs/v2.php/cloud/user') break except ( ConnectionError, Timeout ) as e: - log(nc, LogLvl.DEBUG, "Ignored error during task polling") + log(ncSync, LogLvl.DEBUG, "Ignored error during task polling") i += 1 - sleep(1) + time.sleep(1) continue # ...and set the organizer @@ -105,48 +94,58 @@ def schedule_event(calendar_name: str, title: str, description: str, start_date: # Add event to calendar c.events.add(e) - principal = nc.cal.principal() + principal = ncSync.cal.principal() calendars = principal.calendars() calendar = {cal.name: cal for cal in calendars}[calendar_name] calendar.add_event(str(c)) - return True - - @tool - @safe_tool - def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Optional[float], start_time: Optional[str], end_time: Optional[str]): + @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]): """ - Finds a free time slot where all participants have time - :param participants: The list of participants to find a free slot for (These should be email addresses. If possible use the email addresses from contacts) - :param slot_duration: How long the time slot should be in hours, defaults to one hour - :param start_time: the start time of the range within which to check for free slots (by default this will be now; use the following format: 2025-01-31) - :param end_time: the end time of the range within which to check for free slots (by default this will be 7 days after start_time; use the following format: 2025-01-31) - :return: + 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 + :param title: The title of the event + :param description: The description of the event + :param start_date: the start date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01' + :param end_date: the end date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01' + :param attendees: the list of attendees to add to the event (as email addresses) + :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'). + :return: bool """ + await asyncio.to_thread(schedule_event_sync, calendar_name, title, description, start_date, end_date, attendees, start_time, end_time, location, timezone) - me = nc.ocs('GET', '/ocs/v2.php/cloud/user') + return True - attendees = 'ORGANIZER:mailto:'+me['email']+'\n' - attendees += 'ATTENDEE:mailto:'+me['email']+'\n' - for attendee in participants: - attendees += f"ATTENDEE:mailto:{attendee}\n" + def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration: Optional[float], + start_time: Optional[str], end_time: Optional[str]): + me = ncSync.ocs('GET', '/ocs/v2.php/cloud/user') - if start_time is None: - start_time = round_to_nearest_half_hour(datetime.now(timezone.utc)) - else: - start_time = datetime.combine(datetime.strptime(start_time, "%Y-%m-%d").date(), datetime.min.time(), timezone.utc) - if end_time is None: - end_time = start_time + timedelta(days=7) - else: - end_time = datetime.combine(datetime.strptime(end_time, "%Y-%m-%d").date(), datetime.min.time(), timezone.utc) - if start_time >= end_time: + attendees = 'ORGANIZER:mailto:' + me['email'] + '\n' + attendees += 'ATTENDEE:mailto:' + me['email'] + '\n' + for attendee in participants: + attendees += f"ATTENDEE:mailto:{attendee}\n" + + if start_time is None: + start_time = round_to_nearest_half_hour(datetime.now(timezone.utc)) + else: + start_time = datetime.combine(datetime.strptime(start_time, "%Y-%m-%d").date(), datetime.min.time(), + timezone.utc) + if end_time is None: end_time = start_time + timedelta(days=7) + else: + end_time = datetime.combine(datetime.strptime(end_time, "%Y-%m-%d").date(), datetime.min.time(), + timezone.utc) + if start_time >= end_time: + end_time = start_time + timedelta(days=7) - dtstart = start_time.strftime("%Y%m%dT%H%M%SZ") - dtend = end_time.strftime("%Y%m%dT%H%M%SZ") + dtstart = start_time.strftime("%Y%m%dT%H%M%SZ") + dtend = end_time.strftime("%Y%m%dT%H%M%SZ") - freebusyRequest = """ + freebusyRequest = """ BEGIN:VCALENDAR PRODID:-//IDN nextcloud.com//Calendar app 5.1.0-beta.2//EN CALSCALE:GREGORIAN @@ -160,45 +159,46 @@ def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Opti {ATTENDEES}END:VFREEBUSY END:VCALENDAR """.replace('{ATTENDEES}', attendees).replace('{DTSTART}', dtstart).replace('{DTEND}', dtend) - username = nc._session.user - response = nc._session._create_adapter(True).request('POST', f"{nc.app_cfg.endpoint}/remote.php/dav/calendars/{username}/outbox/", headers={ - "Content-Type": "text/calendar; charset=utf-8", - "Depth": "0", - }, content=freebusyRequest) - print(freebusyRequest) - print(response.text) - - # Parse the XML response to extract vCard data - namespace = {"CAL": "urn:ietf:params:xml:ns:caldav"} # Define the namespace - root = ET.fromstring(response.text) - vcal_elements = root.findall(".//CAL:calendar-data", namespace) - # Parse vcal strings into dictionaries - busy_times = [] - for vcal_element in vcal_elements: - vcal_text = vcal_element.text.strip() - vcal = vobject.readOne(vcal_text) - for fb in vcal.vfreebusy.contents.get("freebusy", []): - busy_times.append(fb.value[0]) - print('busy times', busy_times) - available_slots = find_available_slots(start_time, end_time, busy_times, timedelta(hours=slot_duration)) - print('available_slots', available_slots) - return available_slots - + username = ncSync._session.user + response = ncSync._session._create_adapter(True).request('POST', + f"{nc.app_cfg.endpoint}/remote.php/dav/calendars/{username}/outbox/", + headers={ + "Content-Type": "text/calendar; charset=utf-8", + "Depth": "0", + }, content=freebusyRequest) + print(freebusyRequest) + print(response.text) + + # Parse the XML response to extract vCard data + namespace = {"CAL": "urn:ietf:params:xml:ns:caldav"} # Define the namespace + root = ET.fromstring(response.text) + vcal_elements = root.findall(".//CAL:calendar-data", namespace) + # Parse vcal strings into dictionaries + busy_times = [] + for vcal_element in vcal_elements: + vcal_text = vcal_element.text.strip() + vcal = vobject.readOne(vcal_text) + for fb in vcal.vfreebusy.contents.get("freebusy", []): + busy_times.append(fb.value[0]) + available_slots = find_available_slots(start_time, end_time, busy_times, timedelta(hours=slot_duration)) + return available_slots @tool - @dangerous_tool - def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str], due_time: Optional[str], timezone: Optional[str],): + @safe_tool + async def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Optional[float], start_time: Optional[str], end_time: Optional[str]): """ - 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. - :return: bool + Finds a free time slot where all participants have time + :param participants: The list of participants to find a free slot for (These should be email addresses. If possible use the email addresses from contacts) + :param slot_duration: How long the time slot should be in hours, defaults to one hour + :param start_time: the start time of the range within which to check for free slots (by default this will be now; use the following format: 2025-01-31) + :param end_time: the end time of the range within which to check for free slots (by default this will be 7 days after start_time; use the following format: 2025-01-31) + :return: """ + available_slots = 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]): description_with_ai_note = f"{description}\n\n---\n\nThis task was scheduled by Nextcloud AI Assistant." # Create task @@ -225,17 +225,35 @@ def add_task(calendar_name: str, title: str, description: str, due_date: Optiona due_datetime = tz.localize(due_datetime) t.due = due_datetime - + # Add event to calendar c.todos.add(t) - principal = nc.cal.principal() + principal = ncSync.cal.principal() calendars = principal.calendars() calendar = {cal.name: cal for cal in calendars}[calendar_name] calendar.add_todo(t.serialize()) return True + @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]): + """ + 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. + :return: bool + """ + + asyncio.to_thread(add_task_sync, calendar_name, title, description, due_date, due_time, timezone) + + return True + return [ list_calendars, schedule_event, @@ -246,5 +264,5 @@ def add_task(calendar_name: str, title: str, description: str, due_date: Optiona def get_category_name(): return "Calendar and Tasks" -def is_available(nc: Nextcloud): +async def is_available(nc: AsyncNextcloudApp): return True \ No newline at end of file diff --git a/ex_app/lib/all_tools/contacts.py b/ex_app/lib/all_tools/contacts.py index 0ea4d6a..9d2f8af 100644 --- a/ex_app/lib/all_tools/contacts.py +++ b/ex_app/lib/all_tools/contacts.py @@ -3,24 +3,24 @@ import typing from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp import xml.etree.ElementTree as ET import vobject from ex_app.lib.all_tools.lib.decorator import safe_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def find_person_in_contacts(name: str) -> list[dict[str, typing.Any]]: + async def find_person_in_contacts(name: str) -> list[dict[str, typing.Any]]: """ Find a person's contact information from their name :param name: the name to search for :return: a dictionary with the person's email, phone and address """ username = nc._session.user - response = nc._session._create_adapter(True).request('PROPFIND', f"{nc.app_cfg.endpoint}/remote.php/dav/addressbooks/users/{username}/", headers={ + response = await nc._session._create_adapter(True).request('PROPFIND', f"{nc.app_cfg.endpoint}/remote.php/dav/addressbooks/users/{username}/", headers={ "Content-Type": "application/xml; charset=utf-8", }) print(response.text) @@ -49,7 +49,7 @@ def find_person_in_contacts(name: str) -> list[dict[str, typing.Any]]: """.replace('{NAME}', name) - response = nc._session._create_adapter(True).request('REPORT', f"{nc.app_cfg.endpoint}{link}", headers={ + response = await nc._session._create_adapter(True).request('REPORT', f"{nc.app_cfg.endpoint}{link}", headers={ "Content-Type": "application/xml; charset=utf-8", "Depth": "1", }, content=xml_body) @@ -78,13 +78,13 @@ def find_person_in_contacts(name: str) -> list[dict[str, typing.Any]]: @tool @safe_tool - def find_details_of_current_user() -> dict[str, typing.Any]: + async def find_details_of_current_user() -> dict[str, typing.Any]: """ Find the current user's personal information :return: a dictionary with the person's personal information """ - return nc.ocs('GET', '/ocs/v2.php/cloud/user') + return await nc.ocs('GET', '/ocs/v2.php/cloud/user') return [ @@ -94,5 +94,5 @@ def find_details_of_current_user() -> dict[str, typing.Any]: def get_category_name(): return "Contacts" -def is_available(nc: Nextcloud): +async def is_available(nc: AsyncNextcloudApp): return True \ No newline at end of file diff --git a/ex_app/lib/all_tools/context_chat.py b/ex_app/lib/all_tools/context_chat.py index 3c31b5f..a8e5105 100644 --- a/ex_app/lib/all_tools/context_chat.py +++ b/ex_app/lib/all_tools/context_chat.py @@ -1,17 +1,17 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.task_processing import run_task from ex_app.lib.all_tools.lib.decorator import safe_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def ask_context_chat(question: str) -> str: + async def ask_context_chat(question: str) -> str: """ Ask the context chat oracle a question about the user's documents. It knows the contents of all of the users documents. :param question: The question to ask @@ -24,7 +24,7 @@ def ask_context_chat(question: str) -> str: 'scopeList': [], 'scopeListMeta': '', } - task_output = run_task(nc, "context_chat:context_chat", task_input).output + task_output = (await run_task(nc, "context_chat:context_chat", task_input)).output return task_output['output'] return [ @@ -34,6 +34,6 @@ def ask_context_chat(question: str) -> str: def get_category_name(): return "Context chat" -def is_available(nc: Nextcloud): - tasktypes = nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes')['types'].keys() +async def is_available(nc: AsyncNextcloudApp): + tasktypes = (await nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes'))['types'].keys() return 'context_chat:context_chat' in tasktypes \ No newline at end of file diff --git a/ex_app/lib/all_tools/deck.py b/ex_app/lib/all_tools/deck.py index 8fd5545..31742df 100644 --- a/ex_app/lib/all_tools/deck.py +++ b/ex_app/lib/all_tools/deck.py @@ -1,23 +1,22 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later from langchain_core.tools import tool -from nc_py_api import Nextcloud -from nc_py_api.ex_app import LogLvl +from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def list_boards(): + async def list_boards(): """ List all existing kanban boards available in the Nextcloud Deck app to the current user with their available info :return: a dictionary with all decks of the user """ - response = nc._session._create_adapter(True).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/deck/api/v1.0/boards?details=true", headers={ + response = await nc._session._create_adapter(True).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/deck/api/v1.0/boards?details=true", headers={ "Content-Type": "application/json", }) @@ -27,7 +26,7 @@ def list_boards(): @tool @dangerous_tool - def add_card(board_id: int, stack_id: int, title: str): + async def add_card(board_id: int, stack_id: int, title: str): """ Create a new card in a list of a kanban board in the Nextcloud Deck app. When using this tool, you need to specify in which board and map the card should be created. @@ -37,7 +36,7 @@ def add_card(board_id: int, stack_id: int, title: str): :return: bool """ - response = nc._session._create_adapter(True).request('POST', f"{nc.app_cfg.endpoint}/index.php/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards", headers={ + await nc._session._create_adapter(True).request('POST', f"{nc.app_cfg.endpoint}/index.php/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards", headers={ "Content-Type": "application/json", }, json={ 'title': title, @@ -57,5 +56,5 @@ def add_card(board_id: int, stack_id: int, title: str): def get_category_name(): return "Deck" -def is_available(nc: Nextcloud): - return 'deck' in nc.capabilities \ No newline at end of file +async def is_available(nc: AsyncNextcloudApp): + return 'deck' in await nc.capabilities \ No newline at end of file diff --git a/ex_app/lib/all_tools/doc-gen.py b/ex_app/lib/all_tools/doc-gen.py index 72d3210..48d5059 100644 --- a/ex_app/lib/all_tools/doc-gen.py +++ b/ex_app/lib/all_tools/doc-gen.py @@ -1,24 +1,24 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.task_processing import run_task from ex_app.lib.all_tools.lib.decorator import safe_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def generate_document(input: str, format: str) -> str: + async def generate_document(input: str, format: str) -> str: """ Generate an office document based on a description of what it should contain :param input: the instructions for what the document should contain :param format: the format of the generated file, allowed values are "text document", "pdf", "spreadsheet", "excel spreadsheet" and "slides" :return: a download link to the generated document """ - url = nc.ocs('GET', '/ocs/v2.php/apps/app_api/api/v1/info/nextcloud_url/absolute', json={'url': 'ocs/v2.php/apps/assistant/api/v1/task'}) + url = await nc.ocs('GET', '/ocs/v2.php/apps/app_api/api/v1/info/nextcloud_url/absolute', json={'url': 'ocs/v2.php/apps/assistant/api/v1/task'}) match format: case "text document": @@ -54,10 +54,10 @@ def generate_document(input: str, format: str) -> str: task_input = { 'text': input, } - task = run_task(nc, tasktype, task_input) + task = await run_task(nc, tasktype, task_input) return f"{url}/{task.id}/output-file/{task.output['slide_deck']}/download" - task = run_task(nc, tasktype, task_input) + task = await run_task(nc, tasktype, task_input) return f"{url}/{task.id}/output-file/{task.output['file']}/download" return [ @@ -67,5 +67,5 @@ def generate_document(input: str, format: str) -> str: def get_category_name(): return "Office document generation" -def is_available(nc: Nextcloud): - return 'richdocuments' in nc.capabilities \ No newline at end of file +async def is_available(nc: AsyncNextcloudApp): + return 'richdocuments' in await nc.capabilities \ No newline at end of file diff --git a/ex_app/lib/all_tools/duckduckgo.py b/ex_app/lib/all_tools/duckduckgo.py index a46f587..ecec292 100644 --- a/ex_app/lib/all_tools/duckduckgo.py +++ b/ex_app/lib/all_tools/duckduckgo.py @@ -1,13 +1,10 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later -from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from langchain_community.tools import DuckDuckGoSearchResults -from ex_app.lib.all_tools.lib.decorator import safe_tool - -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): web_search = DuckDuckGoSearchResults(output_format="list") return [ @@ -17,5 +14,5 @@ async def get_tools(nc: Nextcloud): def get_category_name(): return "DuckDuckGo" -def is_available(nc: Nextcloud): +async def is_available(nc: AsyncNextcloudApp): return True diff --git a/ex_app/lib/all_tools/files.py b/ex_app/lib/all_tools/files.py index ef397cb..5f68b62 100644 --- a/ex_app/lib/all_tools/files.py +++ b/ex_app/lib/all_tools/files.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp import niquests from ex_app.lib.all_tools.lib.files import get_file_id_from_file_url @@ -9,20 +9,20 @@ from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def get_file_content(file_path: str): + async def get_file_content(file_path: str): """ Get the content of a file :param file_path: the path of the file - :return: + :return: """ - user_id = nc.ocs('GET', '/ocs/v2.php/cloud/user')["id"] - - response = nc._session._create_adapter(True).request('GET', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{file_path}", headers={ + user_id = (await nc.ocs('GET', '/ocs/v2.php/cloud/user'))["id"] + + response = await nc._session._create_adapter(True).request('GET', f"{nc.app_cfg.endpoint}/remote.php/dav/files/{user_id}/{file_path}", headers={ "Content-Type": "application/json", }) @@ -30,7 +30,7 @@ def get_file_content(file_path: str): @tool @safe_tool - def get_file_content_by_file_link(file_url: str): + async def get_file_content_by_file_link(file_url: str): """ Get the content of a file given an internal Nextcloud link (e.g., https://host/index.php/f/12345) :param file_url: the internal file URL @@ -39,38 +39,38 @@ def get_file_content_by_file_link(file_url: str): file_id = get_file_id_from_file_url(file_url) # Generate a direct download link using the fileId - info = nc.ocs('POST', '/ocs/v2.php/apps/dav/api/v1/direct', json={'fileId': file_id}, response_type='json') + info = await nc.ocs('POST', '/ocs/v2.php/apps/dav/api/v1/direct', json={'fileId': file_id}, response_type='json') download_url = info.get('ocs', {}).get('data', {}).get('url', None) if not download_url: raise Exception('Could not generate download URL from file id') # Download the file from the direct download URL - response = niquests.get(download_url) + response = await niquests.async_api.get(download_url) return response.text @tool @safe_tool - def get_folder_tree(depth: int): + async def get_folder_tree(depth: int): """ Get the folder tree of the user :param depth: the depth of the returned folder tree - :return: + :return: """ - return nc.ocs('GET', '/ocs/v2.php/apps/files/api/v1/folder-tree', json={'depth': depth}, response_type='json') + return await nc.ocs('GET', '/ocs/v2.php/apps/files/api/v1/folder-tree', json={'depth': depth}, response_type='json') @tool @dangerous_tool - def create_public_sharing_link(path: str): + async def create_public_sharing_link(path: str): """ Creates a public sharing link for a file or folder :param path: the path of the file or folder - :return: + :return: """ - response = nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json={ + response = await nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json={ 'path': path, 'shareType': 3, }) @@ -87,5 +87,5 @@ def create_public_sharing_link(path: str): def get_category_name(): return "Files" -def is_available(nc: Nextcloud): +async def is_available(nc: AsyncNextcloudApp): return True \ No newline at end of file diff --git a/ex_app/lib/all_tools/here.py b/ex_app/lib/all_tools/here.py index be44df8..f1350f6 100644 --- a/ex_app/lib/all_tools/here.py +++ b/ex_app/lib/all_tools/here.py @@ -6,16 +6,16 @@ import niquests from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.decorator import safe_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def get_public_transport_route_for_coordinates(origin_lat: str, origin_lon: str, destination_lat: str, destination_lon: str, routes: int, departure_time: str | None = None): + async def get_public_transport_route_for_coordinates(origin_lat: str, origin_lon: str, destination_lat: str, destination_lon: str, routes: int, departure_time: str | None = None): """ Retrieve a public transport route between two coordinates :param origin_lat: Latitude of the starting point @@ -29,8 +29,8 @@ def get_public_transport_route_for_coordinates(origin_lat: str, origin_lon: str, if departure_time is None: departure_time = urllib.parse.quote_plus(datetime.datetime.now(datetime.UTC).isoformat()) - api_key = nc.appconfig_ex.get_value('here_api') - res = niquests.get('https://transit.hereapi.com/v8/routes?transportMode=car&origin=' + api_key = await nc.appconfig_ex.get_value('here_api') + res = await niquests.async_api.get('https://transit.hereapi.com/v8/routes?transportMode=car&origin=' + origin_lat + ',' + origin_lon + '&destination=' + destination_lat + ',' + destination_lon + '&alternatives=' + str(routes-1) + '&departureTime=' + departure_time + '&apikey=' + api_key) json = res.json() @@ -43,5 +43,5 @@ def get_public_transport_route_for_coordinates(origin_lat: str, origin_lon: str, def get_category_name(): return "Public transport" -def is_available(nc: Nextcloud): - return nc.appconfig_ex.get_value('here_api') != '' \ No newline at end of file +async def is_available(nc: AsyncNextcloudApp): + return await nc.appconfig_ex.get_value('here_api') != '' \ No newline at end of file diff --git a/ex_app/lib/all_tools/image_gen.py b/ex_app/lib/all_tools/image_gen.py index eef3532..b406103 100644 --- a/ex_app/lib/all_tools/image_gen.py +++ b/ex_app/lib/all_tools/image_gen.py @@ -1,17 +1,17 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.task_processing import run_task from ex_app.lib.all_tools.lib.decorator import safe_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def generate_image(input: str) -> str: + async def generate_image(input: str) -> str: """ Generate an image using AI with the input string as description :param text: the instructions for the image generation @@ -22,8 +22,8 @@ def generate_image(input: str) -> str: 'input': input, 'numberOfImages': 1, } - task = run_task(nc, tasktype, task_input) - url = nc.ocs('GET', '/ocs/v2.php/apps/app_api/api/v1/info/nextcloud_url/absolute', json={'url': 'ocs/v2.php/apps/assistant/api/v1/task'}) + task = await run_task(nc, tasktype, task_input) + url = await nc.ocs('GET', '/ocs/v2.php/apps/app_api/api/v1/info/nextcloud_url/absolute', json={'url': 'ocs/v2.php/apps/assistant/api/v1/task'}) return f"{url}/{task.id}/output-file/{task.output['images'][0]}/download" return [ @@ -33,6 +33,6 @@ def generate_image(input: str) -> str: def get_category_name(): return "Image generation" -def is_available(nc: Nextcloud): - tasktypes = nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes')['types'].keys() +async def is_available(nc: AsyncNextcloudApp): + tasktypes = (await nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes'))['types'].keys() return 'core:text2image' in tasktypes \ No newline at end of file diff --git a/ex_app/lib/all_tools/lib/decorator.py b/ex_app/lib/all_tools/lib/decorator.py index d82ea8a..31dbf1b 100644 --- a/ex_app/lib/all_tools/lib/decorator.py +++ b/ex_app/lib/all_tools/lib/decorator.py @@ -23,7 +23,7 @@ def decorator(func): async def wrapper(*args): # needs NextcloudApp as first arg nonlocal cached_result nonlocal timestamp - user_id = args[0].user # cache result saved per user + user_id = await args[0].user # cache result saved per user current_time = time.time() if user_id in cached_result: if current_time - timestamp[user_id] < timeout: diff --git a/ex_app/lib/all_tools/lib/task_processing.py b/ex_app/lib/all_tools/lib/task_processing.py index 59e226c..34d1592 100644 --- a/ex_app/lib/all_tools/lib/task_processing.py +++ b/ex_app/lib/all_tools/lib/task_processing.py @@ -1,10 +1,10 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later -import time +import asyncio import typing from niquests import ConnectionError, Timeout -from nc_py_api import NextcloudException +from nc_py_api import AsyncNextcloudApp, NextcloudException from nc_py_api.ex_app import LogLvl from pydantic import BaseModel, ValidationError @@ -19,11 +19,11 @@ class Task(BaseModel): class Response(BaseModel): task: Task -def run_task(nc, type, task_input): +async def run_task(nc: AsyncNextcloudApp, type, task_input): i = 0 while i < 20: try: - response = nc.ocs( + response = await nc.ocs( "POST", "/ocs/v1.php/taskprocessing/schedule", json={"type": type, "appId": "context_agent", "input": task_input}, @@ -34,39 +34,42 @@ def run_task(nc, type, task_input): Timeout ) as e: - log(nc, LogLvl.DEBUG, "Ignored error during task scheduling") + await log(nc, LogLvl.DEBUG, "Ignored error during task scheduling") i += 1 - sleep(1) + await asyncio.sleep(1) continue + if i >= 20: + raise Exception("Failed to schedule task") + try: task = Response.model_validate(response).task - log(nc, LogLvl.DEBUG, task) + await log(nc, LogLvl.DEBUG, task) i = 0 # wait for 5 seconds * 60 * 2 = 10 minutes (one i ^= 5 sec) while task.status != "STATUS_SUCCESSFUL" and task.status != "STATUS_FAILED" and i < 60 * 2: - time.sleep(5) + await asyncio.sleep(5) i += 1 try: - response = nc.ocs("GET", f"/ocs/v1.php/taskprocessing/task/{task.id}") + response = await nc.ocs("GET", f"/ocs/v1.php/taskprocessing/task/{task.id}") except ( ConnectionError, Timeout ) as e: - log(nc, LogLvl.DEBUG, "Ignored error during task polling") - time.sleep(5) + await log(nc, LogLvl.DEBUG, "Ignored error during task polling") + await asyncio.sleep(5) i += 1 continue except NextcloudException as e: if e.status_code == 429: - log(nc, LogLvl.INFO, "Rate limited during task polling, waiting 10s more") - time.sleep(10) + await log(nc, LogLvl.INFO, "Rate limited during task polling, waiting 10s more") + await asyncio.sleep(10) i += 2 continue raise Exception("Nextcloud error when polling task") from e task = Response.model_validate(response).task - log(nc, LogLvl.DEBUG, task) + await log(nc, LogLvl.DEBUG, task) except ValidationError as e: raise Exception("Failed to parse Nextcloud TaskProcessing task result") from e if task.status != "STATUS_SUCCESSFUL": diff --git a/ex_app/lib/all_tools/mail.py b/ex_app/lib/all_tools/mail.py index f4b964a..d8e1ac0 100644 --- a/ex_app/lib/all_tools/mail.py +++ b/ex_app/lib/all_tools/mail.py @@ -1,20 +1,20 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later -from time import sleep +from asyncio import sleep from niquests import ConnectionError, Timeout from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from nc_py_api.ex_app import LogLvl from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool from ex_app.lib.logger import log -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @dangerous_tool - def send_email(subject: str, body: str, account_id: int, from_email: str, to_emails: list[str]): + async def send_email(subject: str, body: str, account_id: int, from_email: str, to_emails: list[str]): """ Send an email to a list of email addresses :param subject: The subject of the email @@ -26,7 +26,7 @@ def send_email(subject: str, body: str, account_id: int, from_email: str, to_ema body_with_ai_note = f"{body}\n\n---\n\nThis email was sent by Nextcloud AI Assistant." while i < 20: try: - return nc.ocs('POST', '/ocs/v2.php/apps/mail/message/send', json={ + return await nc.ocs('POST', '/ocs/v2.php/apps/mail/message/send', json={ 'accountId': account_id, 'fromEmail': from_email, 'subject': subject, @@ -38,14 +38,14 @@ def send_email(subject: str, body: str, account_id: int, from_email: str, to_ema ConnectionError, Timeout ) as e: - log(nc, LogLvl.DEBUG, "Ignored error during task polling") + await log(nc, LogLvl.DEBUG, "Ignored error during task polling") i += 1 - sleep(1) + await sleep(1) continue @tool @safe_tool - def get_mail_account_list(): + async def get_mail_account_list(): """ Lists all available email accounts of the current user including their account id :param subject: The subject of the email @@ -53,8 +53,8 @@ def get_mail_account_list(): :param account_id: The id of the account to send from :param to_emails: The emails to send """ - - return nc.ocs('GET', '/ocs/v2.php/apps/mail/account/list') + + return await nc.ocs('GET', '/ocs/v2.php/apps/mail/account/list') return [ @@ -65,9 +65,9 @@ def get_mail_account_list(): def get_category_name(): return "Mail" -def is_available(nc: Nextcloud): +async def is_available(nc: AsyncNextcloudApp): try: - res = nc.ocs('GET', '/ocs/v2.php/apps/mail/account/list') + await nc.ocs('GET', '/ocs/v2.php/apps/mail/account/list') except: return False return True \ No newline at end of file diff --git a/ex_app/lib/all_tools/mcp.py b/ex_app/lib/all_tools/mcp.py index 65dd35c..ab83696 100644 --- a/ex_app/lib/all_tools/mcp.py +++ b/ex_app/lib/all_tools/mcp.py @@ -3,7 +3,7 @@ from json import JSONDecodeError from langchain_mcp_adapters.client import MultiServerMCPClient -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp import json from ex_app.lib.logger import log from nc_py_api.ex_app import LogLvl @@ -11,12 +11,12 @@ import traceback -async def get_tools(nc: Nextcloud): - mcp_json = nc.appconfig_ex.get_value("mcp_config", "{}") +async def get_tools(nc: AsyncNextcloudApp): + mcp_json = await nc.appconfig_ex.get_value("mcp_config", "{}") try: mcp_config = json.loads(mcp_json) except JSONDecodeError: - log(nc, LogLvl.ERROR, "Invalid MCP json config: " + mcp_json) + await log(nc, LogLvl.ERROR, "Invalid MCP json config: " + mcp_json) mcp_config = {} try: server = MultiServerMCPClient(mcp_config) @@ -26,7 +26,7 @@ async def get_tools(nc: Nextcloud): return tools except Exception as e: tb_str = "".join(traceback.format_exception(e)) - log(nc, LogLvl.ERROR, "Failed to load MCP servers: " + tb_str) + await log(nc, LogLvl.ERROR, "Failed to load MCP servers: " + tb_str) return [] @@ -34,5 +34,5 @@ def get_category_name(): return "MCP Server" -def is_available(nc: Nextcloud): +async def is_available(nc: AsyncNextcloudApp): return True diff --git a/ex_app/lib/all_tools/openproject.py b/ex_app/lib/all_tools/openproject.py index 18818c2..aaf4b2a 100644 --- a/ex_app/lib/all_tools/openproject.py +++ b/ex_app/lib/all_tools/openproject.py @@ -1,45 +1,45 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from typing import Optional from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def list_projects(): + async def list_projects(): """ List all projects in OpenProject :return: list of projects including project IDs - """ - - return nc.ocs('GET', '/ocs/v2.php/apps/integration_openproject/api/v1/projects') + """ + + return await nc.ocs('GET', '/ocs/v2.php/apps/integration_openproject/api/v1/projects') @tool @safe_tool - def list_assignees(project_id: int): + async def list_assignees(project_id: int): """ List all available assignees of a project in OpenProject :param project_id: the ID of the project :return: list of users that can be assigned, including user IDs - """ - - return nc.ocs('GET', f'/ocs/v2.php/apps/integration_openproject/api/v1/projects/{project_id}/available-assignees') + """ + + return await nc.ocs('GET', f'/ocs/v2.php/apps/integration_openproject/api/v1/projects/{project_id}/available-assignees') @tool @dangerous_tool - def create_work_package(project_id: int, title: str, description: Optional[str], assignee_id: Optional[int]): + async def create_work_package(project_id: int, title: str, description: Optional[str], assignee_id: Optional[int]): """ Create a new work package in a given project in OpenProject :param project_id: the ID of the project the work package should be created in, obtainable with list_projects :param title: The title of the work package :param description: The description of the work package :param assignee_id: The ID of the user the work package should be assigned to, obtainable via list_assignees - :return: + :return: """ descrption_with_ai_note = f"{description}\n\nThis work package was created by Nextcloud AI Assistant." @@ -76,7 +76,7 @@ def create_work_package(project_id: int, title: str, description: Optional[str], "raw": descrption_with_ai_note, } - response = nc.ocs('POST', '/ocs/v2.php/apps/integration_openproject/api/v1/create/work-packages', json=json) + await nc.ocs('POST', '/ocs/v2.php/apps/integration_openproject/api/v1/create/work-packages', json=json) return True @@ -91,5 +91,5 @@ def create_work_package(project_id: int, title: str, description: Optional[str], def get_category_name(): return "OpenProject" -def is_available(nc: Nextcloud): - return 'integration_openproject' in nc.capabilities \ No newline at end of file +async def is_available(nc: AsyncNextcloudApp): + return 'integration_openproject' in await nc.capabilities \ No newline at end of file diff --git a/ex_app/lib/all_tools/openstreetmap.py b/ex_app/lib/all_tools/openstreetmap.py index 028ac24..afafd15 100644 --- a/ex_app/lib/all_tools/openstreetmap.py +++ b/ex_app/lib/all_tools/openstreetmap.py @@ -1,28 +1,22 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later -import typing -from typing import Optional -import datetime -import urllib.parse -from time import sleep - import niquests from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.decorator import safe_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def get_coordinates_for_address(address: str) -> (str, str): + async def get_coordinates_for_address(address: str) -> (str, str): """ Calculates the coordinates for a given address :param address: the address to calculate the coordinates for :return: a tuple of latitude and longitude """ - res = niquests.get('https://nominatim.openstreetmap.org/search', params={'q': address, 'format': 'json', 'addressdetails': '1', 'extratags': '1', 'namedetails': '1', 'limit': '1'}) + res = await niquests.async_api.get('https://nominatim.openstreetmap.org/search', params={'q': address, 'format': 'json', 'addressdetails': '1', 'extratags': '1', 'namedetails': '1', 'limit': '1'}) json = res.json() if 'error' in json: raise Exception(json['error']) @@ -33,7 +27,7 @@ def get_coordinates_for_address(address: str) -> (str, str): @tool @safe_tool - def get_osm_route(profile: str, origin_lat: str, origin_lon: str, destination_lat: str, destination_lon: str,): + async def get_osm_route(profile: str, origin_lat: str, origin_lon: str, destination_lat: str, destination_lon: str,): """ Retrieve a route between two coordinates traveled by foot, car or bike :param profile: the kind of transport used to travel the route. Available are 'routed-bike', 'routed-foot', 'routed-car' @@ -56,26 +50,26 @@ def get_osm_route(profile: str, origin_lat: str, origin_lon: str, destination_la profile_num = "2" url = f'https://routing.openstreetmap.de/{profile}/route/v1/driving/{origin_lon},{origin_lat};{destination_lon},{destination_lat}?overview=false&steps=true' map_url = f' https://routing.openstreetmap.de/?loc={origin_lat}%2C{origin_lon}&loc={destination_lat}%2C{destination_lon}&srv={profile_num}' - res = niquests.get(url) + res = await niquests.async_api.get(url) json = res.json() return {'route_json_description': json, 'map_url': map_url} @tool @safe_tool - def get_osm_link(location: str): + async def get_osm_link(location: str): """ - Retrieve a URL for a map of a given location. + Retrieve a URL for a map of a given location. :param location: location name or address :return: URL """ - res = niquests.get('https://nominatim.openstreetmap.org/search', params={'q': location, 'format': 'json','limit': '1'}) + res = await niquests.async_api.get('https://nominatim.openstreetmap.org/search', params={'q': location, 'format': 'json','limit': '1'}) json = res.json() if 'error' in json: raise Exception(json['error']) if len(json) == 0: - raise Exception(f'No results for address {address}') + raise Exception(f'No results for address {location}') osm_id = json[0]['osm_id'] osm_type = json[0]['osm_type'] link = f'https://www.openstreetmap.org/{osm_type}/{osm_id}' @@ -91,5 +85,5 @@ def get_osm_link(location: str): def get_category_name(): return "OpenStreetMap" -def is_available(nc: Nextcloud): +async def is_available(nc: AsyncNextcloudApp): return True \ No newline at end of file diff --git a/ex_app/lib/all_tools/talk.py b/ex_app/lib/all_tools/talk.py index 355e376..fd4fc08 100644 --- a/ex_app/lib/all_tools/talk.py +++ b/ex_app/lib/all_tools/talk.py @@ -1,65 +1,65 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from nc_py_api.talk import ConversationType from ex_app.lib.all_tools.lib.decorator import safe_tool, dangerous_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def list_talk_conversations(): + async def list_talk_conversations(): """ List all conversations of the user in the Nextcloud Talk app :return: returns a list of conversation names, e.g. ["Conversation 1", "Conversation 2"] """ - conversations = nc.talk.get_user_conversations() + conversations = await nc.talk.get_user_conversations() return [conv.display_name for conv in conversations] @tool @dangerous_tool - def create_public_conversation(conversation_name: str) -> str: + async def create_public_conversation(conversation_name: str) -> str: """ Create a new conversation in the Nextcloud Talk app :param conversation_name: The name of the conversation to create :return: The URL of the new conversation """ - conversation = nc.talk.create_conversation(ConversationType.PUBLIC, room_name=conversation_name) + conversation = await nc.talk.create_conversation(ConversationType.PUBLIC, room_name=conversation_name) return f"{nc.app_cfg.endpoint}/index.php/call/{conversation.token}" @tool @dangerous_tool - def send_message_to_conversation(conversation_name: str, message: str): + async def send_message_to_conversation(conversation_name: str, message: str): """ List all conversations in the Nextcloud talk app :param message: The message to send :param conversation_name: The name of the conversation to send a message to :return: """ - conversations = nc.talk.get_user_conversations() + conversations = await nc.talk.get_user_conversations() conversation = {conv.display_name: conv for conv in conversations}[conversation_name] message_with_ai_note = f"{message}\n\nThis message was sent by Nextcloud AI Assistant." - nc.talk.send_message(message_with_ai_note, conversation) + await nc.talk.send_message(message_with_ai_note, conversation) return True @tool @safe_tool - def list_messages_in_conversation(conversation_name: str, n_messages: int = 30): + async def list_messages_in_conversation(conversation_name: str, n_messages: int = 30): """ List messages of a conversation in the Nextcloud Talk app :param conversation_name: The name of the conversation to list messages of (can only be one conversation per Tool call, obtainable via list_talk_conversations) :param n_messages: The number of messages to receive :return: A list of messages """ - conversations = nc.talk.get_user_conversations() + conversations = await nc.talk.get_user_conversations() conversation = {conv.display_name: conv for conv in conversations}[conversation_name] - return [f"{m.timestamp} {m.actor_display_name}: {m.message}" for m in nc.talk.receive_messages(conversation, False, n_messages)] + return [f"{m.timestamp} {m.actor_display_name}: {m.message}" for m in await nc.talk.receive_messages(conversation, False, n_messages)] return [ list_talk_conversations, @@ -71,5 +71,5 @@ def list_messages_in_conversation(conversation_name: str, n_messages: int = 30): def get_category_name(): return "Talk" -def is_available(nc: Nextcloud): - return 'spreed' in nc.capabilities +async def is_available(nc: AsyncNextcloudApp): + return 'spreed' in await nc.capabilities diff --git a/ex_app/lib/all_tools/weather.py b/ex_app/lib/all_tools/weather.py index db420ae..24be8b8 100644 --- a/ex_app/lib/all_tools/weather.py +++ b/ex_app/lib/all_tools/weather.py @@ -1,26 +1,25 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later import typing -import urllib.parse import niquests from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.decorator import safe_tool -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def get_current_weather_for_coordinates(lat: str, lon: str) -> dict[str, typing.Any]: + async def get_current_weather_for_coordinates(lat: str, lon: str) -> dict[str, typing.Any]: """ Retrieve the current weather for a given latitude and longitude :param lat: Latitude :param lon: Longitude :return: """ - res = niquests.get('https://api.met.no/weatherapi/locationforecast/2.0/compact', params={ + res = await niquests.async_api.get('https://api.met.no/weatherapi/locationforecast/2.0/compact', params={ 'lat': lat, 'lon': lon, }, @@ -39,5 +38,5 @@ def get_current_weather_for_coordinates(lat: str, lon: str) -> dict[str, typing. def get_category_name(): return "Weather" -def is_available(nc: Nextcloud): +async def is_available(nc: AsyncNextcloudApp): return True \ No newline at end of file diff --git a/ex_app/lib/all_tools/youtube.py b/ex_app/lib/all_tools/youtube.py index cb4cab2..4e7875a 100644 --- a/ex_app/lib/all_tools/youtube.py +++ b/ex_app/lib/all_tools/youtube.py @@ -1,13 +1,10 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later -from langchain_core.tools import tool -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from langchain_community.tools import YouTubeSearchTool -from ex_app.lib.all_tools.lib.decorator import safe_tool - -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): yt_search = YouTubeSearchTool() return [ @@ -17,5 +14,5 @@ async def get_tools(nc: Nextcloud): def get_category_name(): return "YouTube" -def is_available(nc: Nextcloud): +async def is_available(nc: AsyncNextcloudApp): return True diff --git a/ex_app/lib/logger.py b/ex_app/lib/logger.py index 06f914c..bdf23c9 100644 --- a/ex_app/lib/logger.py +++ b/ex_app/lib/logger.py @@ -1,14 +1,17 @@ # SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import asyncio import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[logging.StreamHandler()]) logger = logging.getLogger('context_agent') logger.setLevel(logging.INFO) -def log(nc, level, content): +async def log(nc, level, content): logger.log((level+1)*10, content) try: - nc.log(level, content) - except: + await nc.log(level, content) + except asyncio.CancelledError: + raise + except Exception: pass \ No newline at end of file diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index aac0f75..8a1abcd 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -11,7 +11,7 @@ from niquests import RequestException import json from fastapi import FastAPI -from nc_py_api import NextcloudApp, NextcloudException +from nc_py_api import NextcloudApp, NextcloudException, AsyncNextcloudApp from nc_py_api.ex_app import ( AppAPIAuthMiddleware, LogLvl, @@ -109,83 +109,92 @@ async def exapp_lifespan(app: FastAPI): ) -def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: +async def enabled_handler(enabled: bool, nc: AsyncNextcloudApp) -> str: # This will be called each time application is `enabled` or `disabled` # NOTE: `user` is unavailable on this step, so all NC API calls that require it will fail as unauthorized. - log(nc, LogLvl.INFO, f"enabled={enabled}") + await log(nc, LogLvl.INFO, f"enabled={enabled}") if enabled: - nc.providers.task_processing.register(provider) + await nc.providers.task_processing.register(provider) app_enabled.set() - log(nc, LogLvl.WARNING, f"App enabled: {nc.app_cfg.app_name}") + await log(nc, LogLvl.WARNING, f"App enabled: {nc.app_cfg.app_name}") - nc.ui.settings.register_form(SETTINGS) - pref_settings = json.loads(nc.appconfig_ex.get_value('tool_status', default = "{}")) + await nc.ui.settings.register_form(SETTINGS) + pref_settings = json.loads(await nc.appconfig_ex.get_value('tool_status', default = "{}")) for key in categories.keys(): # populate new settings values if key not in pref_settings: pref_settings[key] = True - nc.appconfig_ex.set_value('tool_status', json.dumps(pref_settings)) + await nc.appconfig_ex.set_value('tool_status', json.dumps(pref_settings)) else: - nc.providers.task_processing.unregister(provider.id) + await nc.providers.task_processing.unregister(provider.id) app_enabled.clear() - log(nc, LogLvl.WARNING, f"App disabled: {nc.app_cfg.app_name}") + await log(nc, LogLvl.WARNING, f"App disabled: {nc.app_cfg.app_name}") # In case of an error, a non-empty short string should be returned, which will be shown to the NC administrator. return "" async def background_thread_task(): - nc = NextcloudApp() + nc = AsyncNextcloudApp() - while True: - if not app_enabled.is_set(): - await asyncio.sleep(5) - continue + async with asyncio.TaskGroup() as tg: + while True: + if not app_enabled.is_set(): + await asyncio.sleep(5) + continue - try: - response = nc.providers.task_processing.next_task([provider.id], [provider.task_type]) - if not response or not 'task' in response: - await wait_for_task() + try: + response = await nc.providers.task_processing.next_task([provider.id], [provider.task_type]) + if not response or not 'task' in response: + if NUM_RUNNING_TASKS == 0: + # if there are no running tasks we will get a trigger + await wait_for_task() + else: + # otherwise, wait with fast frequency + await asyncio.sleep(2) + continue + except (NextcloudException, RequestException, JSONDecodeError) as e: + tb_str = ''.join(traceback.format_exception(e)) + await log(nc, LogLvl.WARNING, "Error fetching the next task " + tb_str) + await wait_for_task(5) continue - except (NextcloudException, RequestException, JSONDecodeError) as e: - tb_str = ''.join(traceback.format_exception(e)) - log(nc, LogLvl.WARNING, "Error fetching the next task " + tb_str) - await wait_for_task(5) - continue - except RequestException as e: - log(nc, LogLvl.DEBUG, "Ignored error during task polling") - await wait_for_task(2) - continue - - task = response["task"] - log(nc, LogLvl.INFO, 'New Task incoming') - log(nc, LogLvl.DEBUG, str(task)) - log(nc, LogLvl.INFO, str({'input': task['input']['input'], 'confirmation': task['input']['confirmation'], 'conversation_token': '', 'memories': task['input'].get('memories', None)})) - asyncio.create_task(handle_task(task, nc)) - - -async def handle_task(task, nc: NextcloudApp): + + task = response["task"] + await log(nc, LogLvl.INFO, 'New Task incoming') + await log(nc, LogLvl.DEBUG, str(task)) + await log(nc, LogLvl.INFO, str({'input': task['input']['input'], 'confirmation': task['input']['confirmation'], 'conversation_token': '', 'memories': task['input'].get('memories', None)})) + tg.create_task(handle_task(task, nc)) + +NUM_RUNNING_TASKS = 0 + +async def handle_task(task, nc: AsyncNextcloudApp): + global NUM_RUNNING_TASKS try: - nextcloud = NextcloudApp() + NUM_RUNNING_TASKS += 1 + nextcloud = AsyncNextcloudApp() if task['userId']: - nextcloud.set_user(task['userId']) + await nextcloud.set_user(task['userId']) output = await react(task, nextcloud) except Exception as e: # noqa - tb_str = ''.join(traceback.format_exception(e)) - log(nc, LogLvl.ERROR, "Error: " + tb_str) try: - nc.providers.task_processing.report_result(task["id"], error_message=str(e)) + tb_str = ''.join(traceback.format_exception(e)) + await log(nc, LogLvl.ERROR, "Error: " + tb_str) + await nc.providers.task_processing.report_result(task["id"], error_message=str(e)) except (NextcloudException, RequestException) as net_err: tb_str = ''.join(traceback.format_exception(net_err)) - log(nc, LogLvl.WARNING, "Network error in reporting the error: " + tb_str) + await log(nc, LogLvl.WARNING, "Network error in reporting the error: " + tb_str) + finally: + NUM_RUNNING_TASKS -= 1 return try: - NextcloudApp().providers.task_processing.report_result( + await nc.providers.task_processing.report_result( task["id"], output, ) except (NextcloudException, RequestException, JSONDecodeError) as e: tb_str = ''.join(traceback.format_exception(e)) - log(nc, LogLvl.ERROR, "Network error trying to report the task result: " + tb_str) + await log(nc, LogLvl.ERROR, "Network error trying to report the task result: " + tb_str) + finally: + NUM_RUNNING_TASKS -= 1 @@ -195,6 +204,7 @@ def start_bg_task(): # Trigger event is available starting with nextcloud v33 def trigger_handler(providerId: str): + # runs in a separate thread from the main thread, which is why we need threading.Event global TRIGGER TRIGGER.set() diff --git a/ex_app/lib/mcp_server.py b/ex_app/lib/mcp_server.py index cb1e09e..060bf32 100644 --- a/ex_app/lib/mcp_server.py +++ b/ex_app/lib/mcp_server.py @@ -4,14 +4,14 @@ from functools import wraps from fastmcp.server.dependencies import get_context -from nc_py_api import NextcloudApp +from nc_py_api import AsyncNextcloudApp, NextcloudApp from fastmcp.server.middleware import Middleware, MiddlewareContext, CallNext from fastmcp.tools import Tool from mcp import types as mt from ex_app.lib.tools import get_tools import requests -def get_user(authorization_header: str, nc: NextcloudApp) -> str: +def get_user(authorization_header: str, nc: AsyncNextcloudApp) -> str: response = requests.get( f"{nc.app_cfg.endpoint}/ocs/v2.php/cloud/user", headers={ @@ -31,9 +31,9 @@ async def on_message(self, context: MiddlewareContext, call_next): authorization_header = context.fastmcp_context.request_context.request.headers.get("Authorization") if authorization_header is None: raise Exception("Authorization header is missing/invalid") - nc = NextcloudApp() + nc = AsyncNextcloudApp() user = get_user(authorization_header, nc) - nc.set_user(user) + await nc.set_user(user) context.fastmcp_context.set_state("nextcloud", nc) return await call_next(context) diff --git a/ex_app/lib/nc_model.py b/ex_app/lib/nc_model.py index cf2d6cb..cc060f9 100644 --- a/ex_app/lib/nc_model.py +++ b/ex_app/lib/nc_model.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import asyncio import json -import time import typing from typing import Optional, Any, Sequence, Union, Callable @@ -13,7 +13,7 @@ from langchain_core.runnables import Runnable from langchain_core.tools import BaseTool from langchain_core.utils.function_calling import convert_to_openai_tool -from nc_py_api import Nextcloud, NextcloudApp, NextcloudException +from nc_py_api import AsyncNextcloudApp, NextcloudException from nc_py_api.ex_app import LogLvl from pydantic import BaseModel, ValidationError @@ -33,13 +33,16 @@ class Response(BaseModel): # Custom formatting for chat inputs class ChatWithNextcloud(BaseChatModel): - nc: Nextcloud = NextcloudApp() + nc: AsyncNextcloudApp = AsyncNextcloudApp() tools: Sequence[ Union[typing.Dict[str, Any], type, Callable, BaseTool]] = [] TIMEOUT: int = 60 * 20 # 20 minutes MAX_MESSAGE_HISTORY: int = 13 - def _generate( + def _generate(self, messages: list[BaseMessage], stop: Optional[list[str]] = None, run_manager: Optional[CallbackManagerForLLMRun] = None, **kwargs: Any): + raise Exception("Use _agenerate instead") + + async def _agenerate( self, messages: list[BaseMessage], stop: Optional[list[str]] = None, @@ -88,12 +91,12 @@ def _generate( task_input['tool_message'] = '' - log(nc, LogLvl.DEBUG, task_input) + await log(nc, LogLvl.DEBUG, task_input) i = 0 while i < 20: try: - response = nc.ocs( + response = await nc.ocs( "POST", "/ocs/v1.php/taskprocessing/schedule", json={"type": "core:text2text:chatwithtools", "appId": "context_agent", "input": task_input}, @@ -104,41 +107,44 @@ def _generate( Timeout ) as e: - log(nc, LogLvl.DEBUG, "Ignored error during task scheduling") + await log(nc, LogLvl.DEBUG, "Ignored error during task scheduling") i += 1 - sleep(1) + await asyncio.sleep(1) continue + if i >= 20: + raise Exception("Failed to schedule task") + try: task = Response.model_validate(response).task - log(nc, LogLvl.DEBUG, task) + await log(nc, LogLvl.DEBUG, task) i = 0 wait_time = 5 # wait for TIMEOUT (one i ^= 5 sec) while task.status != "STATUS_SUCCESSFUL" and task.status != "STATUS_FAILED" and i < self.TIMEOUT / wait_time: - time.sleep(wait_time) + await asyncio.sleep(wait_time) i += 1 try: - response = nc.ocs("GET", f"/ocs/v1.php/taskprocessing/task/{task.id}") + response = await nc.ocs("GET", f"/ocs/v1.php/taskprocessing/task/{task.id}") except ( ConnectionError, Timeout ) as e: - log(nc, LogLvl.DEBUG, "Ignored error during task polling") - time.sleep(5) + await log(nc, LogLvl.DEBUG, "Ignored error during task polling") + await asyncio.sleep(5) i += 1 continue except NextcloudException as e: if e.status_code == 429: - log(nc, LogLvl.INFO, "Rate limited during task polling, waiting 10s more") - time.sleep(10) + await log(nc, LogLvl.INFO, "Rate limited during task polling, waiting 10s more") + await asyncio.sleep(10) i += 2 continue raise Exception("Nextcloud error when polling task") from e task = Response.model_validate(response).task - log(nc, LogLvl.DEBUG, task) + await log(nc, LogLvl.DEBUG, task) except ValidationError as e: raise Exception("Failed to parse Nextcloud TaskProcessing task result") from e @@ -169,7 +175,7 @@ def bind_tools( return self def bind_nextcloud(self, - nc: Nextcloud): + nc: AsyncNextcloudApp): self.nc = nc def _llm_type(self) -> str: diff --git a/ex_app/lib/tools.py b/ex_app/lib/tools.py index a011075..752b941 100644 --- a/ex_app/lib/tools.py +++ b/ex_app/lib/tools.py @@ -6,11 +6,11 @@ import json from os.path import dirname -from nc_py_api import Nextcloud +from nc_py_api import AsyncNextcloudApp from ex_app.lib.all_tools.lib.decorator import timed_memoize @timed_memoize(1*60) -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): directory = dirname(__file__) + '/all_tools' function_name = "get_tools" @@ -18,7 +18,7 @@ async def get_tools(nc: Nextcloud): safe_tools = [] py_files = [f for f in os.listdir(directory) if f.endswith(".py") and f != "__init__.py"] - is_activated = json.loads(nc.appconfig_ex.get_value('tool_status')) + is_activated = json.loads(await nc.appconfig_ex.get_value('tool_status')) for file in py_files: # Load module dynamically @@ -28,20 +28,21 @@ async def get_tools(nc: Nextcloud): if hasattr(module, function_name): get_tools_from_import = getattr(module, function_name) available_from_import = getattr(module, "is_available") - if not is_activated[module_name]: + if not is_activated.get(module_name, False): print(f"{module_name} tools deactivated") continue - if not available_from_import(nc): + if not await available_from_import(nc): print(f"{module_name} not available") continue if callable(get_tools_from_import): print(f"Invoking {function_name} from {module_name}") imported_tools = await get_tools_from_import(nc) for tool in imported_tools: - if not hasattr(tool, 'func'): + tool_action = getattr(tool, 'coroutine', getattr(tool, 'func', None)) + if tool_action is None: safe_tools.append(tool) continue - if not hasattr(tool.func, 'safe') or not tool.func.safe: + if not getattr(tool_action, 'safe', False): dangerous_tools.append(tool) # MCP tools cannot be decorated and should always be dangerous else: safe_tools.append(tool) diff --git a/poetry.lock b/poetry.lock index 9b1e967..fd4f823 100644 --- a/poetry.lock +++ b/poetry.lock @@ -145,7 +145,6 @@ files = [ [package.dependencies] aiohappyeyeballs = ">=2.5.0" aiosignal = ">=1.4.0" -async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" @@ -196,7 +195,6 @@ files = [ ] [package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} @@ -226,19 +224,6 @@ types-python-dateutil = ">=2.8.10" doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] -[[package]] -name = "async-timeout" -version = "4.0.3" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.7" -groups = ["main"] -markers = "python_version == \"3.10\"" -files = [ - {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, - {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, -] - [[package]] name = "attrs" version = "25.3.0" @@ -281,7 +266,7 @@ description = "Backport of CPython tarfile module" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "python_version < \"3.12\"" +markers = "python_version == \"3.11\"" files = [ {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, @@ -646,7 +631,6 @@ attrs = ">=23.1.0" docstring-parser = {version = ">=0.15", markers = "python_version < \"4.0\""} rich = ">=13.6.0" rich-rst = ">=1.3.1,<2.0.0" -typing-extensions = {version = ">=4.8.0", markers = "python_version < \"3.11\""} [package.extras] toml = ["tomli (>=2.0.0) ; python_version < \"3.11\""] @@ -1149,7 +1133,7 @@ description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_version < \"3.12\"" +markers = "python_version == \"3.11\"" files = [ {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, @@ -1492,7 +1476,6 @@ files = [ ] [package.dependencies] -async-timeout = {version = ">=4.0.0,<5.0.0", markers = "python_version < \"3.11\""} langchain-core = ">=0.3.58,<1.0.0" langchain-text-splitters = ">=0.3.8,<1.0.0" langsmith = ">=0.1.17,<0.4" @@ -2065,9 +2048,6 @@ files = [ {file = "multidict-6.4.3.tar.gz", hash = "sha256:3ada0b058c9f213c5f95ba301f922d402ac234f1111a7d8fd70f1b99f3c281ec"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} - [[package]] name = "mypy-extensions" version = "1.1.0" @@ -2981,7 +2961,7 @@ description = "A lightway and fast implementation of QUIC and HTTP/3" optional = false python-versions = ">=3.7" groups = ["main"] -markers = "(platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and (platform_python_implementation == \"CPython\" or platform_python_implementation == \"PyPy\" and python_version < \"3.12\")" +markers = "(platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and (platform_python_implementation == \"CPython\" or platform_python_implementation == \"PyPy\" and python_version == \"3.11\")" files = [ {file = "qh3-1.5.5-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:67dc1332ed84ef81a0f0bce79a464bdc43f4d695bd701dcb275287241d4d6cc5"}, {file = "qh3-1.5.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3649b42a9e045e6a76e24b9cbeb2c4fff8cb7aca5d899bb9da40f04eec1e5179"}, @@ -3711,7 +3691,6 @@ files = [ [package.dependencies] click = ">=7.0" h11 = ">=0.8" -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] @@ -4127,7 +4106,7 @@ description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_version < \"3.12\"" +markers = "python_version == \"3.11\"" files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, @@ -4256,5 +4235,5 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" -python-versions = "^3.10" -content-hash = "f5266cfc8797e210c471b6c96ba26325608a9e1ac1930dcbcdab8030d888f760" +python-versions = ">=3.11,<4" +content-hash = "99c6c57cb9fec8197da94b0c9d5045b159bb0edbd82242ae794a238d365e0f97" diff --git a/pyproject.toml b/pyproject.toml index 8e90793..35a87b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" package-mode = false [tool.poetry.dependencies] -python = "^3.10" +python = ">=3.11,<4" nc-py-api = {extras = ["calendar"], version = "^0.22.0"} langgraph = "1.*" langchain = "^0.3.25" @@ -44,7 +44,7 @@ extend-ignore = ["D101", "D102", "D103", "D105", "D107", "D203", "D213", "D401", profile = "black" [tool.pylint] -master.py-version = "3.10" +master.py-version = "3.11" master.extension-pkg-allow-list = ["pydantic"] design.max-attributes = 8 design.max-locals = 16