From 6f0fbe2a60461441b80033cbad0914ecd546e4f1 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 5 Feb 2026 13:39:25 +0100 Subject: [PATCH 01/16] fix: Minor fixes Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/audio2text.py | 2 +- ex_app/lib/all_tools/lib/task_processing.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ex_app/lib/all_tools/audio2text.py b/ex_app/lib/all_tools/audio2text.py index 395d747..228897a 100644 --- a/ex_app/lib/all_tools/audio2text.py +++ b/ex_app/lib/all_tools/audio2text.py @@ -15,7 +15,7 @@ async def get_tools(nc: Nextcloud): 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 """ diff --git a/ex_app/lib/all_tools/lib/task_processing.py b/ex_app/lib/all_tools/lib/task_processing.py index 59e226c..8f62682 100644 --- a/ex_app/lib/all_tools/lib/task_processing.py +++ b/ex_app/lib/all_tools/lib/task_processing.py @@ -36,7 +36,7 @@ def run_task(nc, type, task_input): ) as e: log(nc, LogLvl.DEBUG, "Ignored error during task scheduling") i += 1 - sleep(1) + time.sleep(1) continue try: From 33a434f38b10337161b7a6ad956c6fc7ec5a5543 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 5 Feb 2026 15:12:49 +0100 Subject: [PATCH 02/16] perf: Make everything async Signed-off-by: Marcel Klehr --- ex_app/lib/agent.py | 4 +- ex_app/lib/all_tools/audio2text.py | 12 +++--- ex_app/lib/all_tools/calendar.py | 28 ++++++------ ex_app/lib/all_tools/contacts.py | 16 +++---- ex_app/lib/all_tools/context_chat.py | 12 +++--- ex_app/lib/all_tools/deck.py | 16 +++---- ex_app/lib/all_tools/doc-gen.py | 16 +++---- ex_app/lib/all_tools/duckduckgo.py | 9 ++-- ex_app/lib/all_tools/files.py | 34 +++++++-------- ex_app/lib/all_tools/here.py | 14 +++--- ex_app/lib/all_tools/image_gen.py | 14 +++--- ex_app/lib/all_tools/lib/task_processing.py | 30 +++++++------ ex_app/lib/all_tools/mail.py | 22 +++++----- ex_app/lib/all_tools/mcp.py | 12 +++--- ex_app/lib/all_tools/openproject.py | 30 ++++++------- ex_app/lib/all_tools/openstreetmap.py | 28 +++++------- ex_app/lib/all_tools/talk.py | 28 ++++++------ ex_app/lib/all_tools/weather.py | 11 +++-- ex_app/lib/all_tools/youtube.py | 9 ++-- ex_app/lib/logger.py | 5 ++- ex_app/lib/main.py | 48 ++++++++++----------- ex_app/lib/nc_model.py | 36 +++++++++------- ex_app/lib/tools.py | 12 +++--- 23 files changed, 221 insertions(+), 225 deletions(-) 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 228897a..3afab09 100644 --- a/ex_app/lib/all_tools/audio2text.py +++ b/ex_app/lib/all_tools/audio2text.py @@ -1,18 +1,18 @@ # 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 (e.g. https://host/index.php/f/1234 - The user can input this using the smart picker for example) @@ -22,7 +22,7 @@ def transcribe_file(file_url: str) -> str: 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..40a20e9 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -1,14 +1,14 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import asyncio 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 from nc_py_api.ex_app import LogLvl import xml.etree.ElementTree as ET import vobject @@ -18,11 +18,11 @@ from ex_app.lib.logger import log -async def get_tools(nc: Nextcloud): +async def get_tools(nc: AsyncNextcloudApp): @tool @safe_tool - def list_calendars(): + async def list_calendars(): """ List all existing calendars by name :return: @@ -33,7 +33,7 @@ def list_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]): + 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]): """ 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 @@ -94,9 +94,9 @@ def schedule_event(calendar_name: str, title: str, description: str, start_date: 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 asyncio.sleep(1) continue # ...and set the organizer @@ -115,7 +115,7 @@ def schedule_event(calendar_name: str, title: str, description: str, start_date: @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]): + async def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Optional[float], start_time: Optional[str], end_time: 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) @@ -125,7 +125,7 @@ def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Opti :return: """ - me = nc.ocs('GET', '/ocs/v2.php/cloud/user') + me = await nc.ocs('GET', '/ocs/v2.php/cloud/user') attendees = 'ORGANIZER:mailto:'+me['email']+'\n' attendees += 'ATTENDEE:mailto:'+me['email']+'\n' @@ -161,7 +161,7 @@ def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Opti 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={ + response = await 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) @@ -187,15 +187,15 @@ def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Opti @tool @dangerous_tool - 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], due_time: Optional[str], timezone: Optional[str],): """ - Crete a new task in a calendar. + 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'). Is required if there is a specified due date. :return: bool """ @@ -246,5 +246,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..c1d8e2a 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..6460d98 100644 --- a/ex_app/lib/all_tools/deck.py +++ b/ex_app/lib/all_tools/deck.py @@ -1,23 +1,23 @@ # 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.ex_app import LogLvl 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 +27,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 +37,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={ + response = 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 +57,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..702a5f2 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/task_processing.py b/ex_app/lib/all_tools/lib/task_processing.py index 8f62682..4f6f917 100644 --- a/ex_app/lib/all_tools/lib/task_processing.py +++ b/ex_app/lib/all_tools/lib/task_processing.py @@ -1,10 +1,11 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import asyncio import time 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 +20,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 +35,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 - time.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..bd53249 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) 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') + res = 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..ec8bcf8 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) + response = 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..ad18fd4 100644 --- a/ex_app/lib/logger.py +++ b/ex_app/lib/logger.py @@ -1,14 +1,15 @@ # 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) + await nc.log(level, content) except: pass \ No newline at end of file diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index aac0f75..6c1495a 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,32 +109,32 @@ 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(): @@ -142,41 +142,41 @@ async def background_thread_task(): continue try: - response = nc.providers.task_processing.next_task([provider.id], [provider.task_type]) + response = await nc.providers.task_processing.next_task([provider.id], [provider.task_type]) if not response or not 'task' in response: await wait_for_task() 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 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 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)})) + 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)})) asyncio.create_task(handle_task(task, nc)) -async def handle_task(task, nc: NextcloudApp): +async def handle_task(task, nc: AsyncNextcloudApp): try: - nextcloud = NextcloudApp() + 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) + await log(nc, LogLvl.ERROR, "Error: " + tb_str) try: - nc.providers.task_processing.report_result(task["id"], error_message=str(e)) + 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) return try: NextcloudApp().providers.task_processing.report_result( @@ -185,7 +185,7 @@ async def handle_task(task, nc: NextcloudApp): ) 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) diff --git a/ex_app/lib/nc_model.py b/ex_app/lib/nc_model.py index cf2d6cb..62d791e 100644 --- a/ex_app/lib/nc_model.py +++ b/ex_app/lib/nc_model.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import asyncio import json import time import typing @@ -13,7 +14,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 Nextcloud, AsyncNextcloudApp, NextcloudException from nc_py_api.ex_app import LogLvl from pydantic import BaseModel, ValidationError @@ -33,13 +34,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 +92,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 +108,41 @@ 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 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 +173,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..d97efc4 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 @@ -31,17 +31,17 @@ async def get_tools(nc: Nextcloud): if not is_activated[module_name]: 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'): + if not hasattr(tool, 'coroutine') and not hasattr(tool, 'func'): safe_tools.append(tool) continue - if not hasattr(tool.func, 'safe') or not tool.func.safe: + if (not hasattr(tool.coroutine, 'safe') or not tool.coroutine.safe) and (not hasattr(tool.func, 'safe') or not tool.coroutine.sage): dangerous_tools.append(tool) # MCP tools cannot be decorated and should always be dangerous else: safe_tools.append(tool) From 122a3cbcd72858729da720915542a16c173b144f Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 5 Feb 2026 17:48:57 +0100 Subject: [PATCH 03/16] fix: More async fixes Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/calendar.py | 16 ++++++++-------- ex_app/lib/main.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index 40a20e9..af7a394 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -8,7 +8,7 @@ import pytz from ics import Calendar, Event, Attendee, Organizer, Todo from langchain_core.tools import tool -from nc_py_api import AsyncNextcloudApp +from nc_py_api import AsyncNextcloudApp, NextcloudApp from nc_py_api.ex_app import LogLvl import xml.etree.ElementTree as ET import vobject @@ -19,15 +19,17 @@ async def get_tools(nc: AsyncNextcloudApp): + ncSync = NextcloudApp() + ncSync.set_user(await nc.user) @tool @safe_tool async def list_calendars(): """ List all existing calendars by name - :return: + :return: a comma-separated list of calendar names """ - principal = nc.cal.principal() + principal = ncSync.cal.principal() calendars = principal.calendars() return ", ".join([cal.name for cal in calendars]) @@ -88,7 +90,7 @@ async def schedule_event(calendar_name: str, title: str, description: str, start i = 0 while i < 20: try: - json = nc.ocs('GET', '/ocs/v2.php/cloud/user') + json = await nc.ocs('GET', '/ocs/v2.php/cloud/user') break except ( ConnectionError, @@ -105,7 +107,7 @@ async def schedule_event(calendar_name: str, title: str, description: str, start # 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)) @@ -179,9 +181,7 @@ async def find_free_time_slot_in_calendar(participants: list[str], slot_duration 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 @@ -229,7 +229,7 @@ async def add_task(calendar_name: str, title: str, description: str, due_date: O # 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()) diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 6c1495a..b9c3a23 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -179,7 +179,7 @@ async def handle_task(task, nc: AsyncNextcloudApp): await log(nc, LogLvl.WARNING, "Network error in reporting the error: " + tb_str) return try: - NextcloudApp().providers.task_processing.report_result( + await nc.providers.task_processing.report_result( task["id"], output, ) From 2d1c2fd1b38878680f79fbb3ab28ca9edf670a64 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 5 Feb 2026 18:42:07 +0100 Subject: [PATCH 04/16] fix: Apply suggestion from @marcelklehr Signed-off-by: Marcel Klehr --- ex_app/lib/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ex_app/lib/tools.py b/ex_app/lib/tools.py index d97efc4..f6e704e 100644 --- a/ex_app/lib/tools.py +++ b/ex_app/lib/tools.py @@ -41,7 +41,7 @@ async def get_tools(nc: AsyncNextcloudApp): if not hasattr(tool, 'coroutine') and not hasattr(tool, 'func'): safe_tools.append(tool) continue - if (not hasattr(tool.coroutine, 'safe') or not tool.coroutine.safe) and (not hasattr(tool.func, 'safe') or not tool.coroutine.sage): + if (not hasattr(tool.coroutine, 'safe') or not tool.coroutine.safe) or (not hasattr(tool.func, 'safe') or not tool.coroutine.safe): dangerous_tools.append(tool) # MCP tools cannot be decorated and should always be dangerous else: safe_tools.append(tool) From a61952af5486e0254806a5bfb6d853cbdbb5074c Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 10 Feb 2026 09:29:38 +0100 Subject: [PATCH 05/16] fix: Address review comments --- ex_app/lib/all_tools/audio2text.py | 2 +- ex_app/lib/all_tools/context_chat.py | 2 +- ex_app/lib/all_tools/deck.py | 3 +-- ex_app/lib/all_tools/files.py | 2 +- ex_app/lib/all_tools/lib/task_processing.py | 1 - ex_app/lib/all_tools/mail.py | 4 ++-- ex_app/lib/all_tools/openproject.py | 2 +- ex_app/lib/logger.py | 4 +++- ex_app/lib/main.py | 4 ---- ex_app/lib/nc_model.py | 6 ++++-- ex_app/lib/tools.py | 5 +++-- 11 files changed, 17 insertions(+), 18 deletions(-) diff --git a/ex_app/lib/all_tools/audio2text.py b/ex_app/lib/all_tools/audio2text.py index 3afab09..6a9b0da 100644 --- a/ex_app/lib/all_tools/audio2text.py +++ b/ex_app/lib/all_tools/audio2text.py @@ -22,7 +22,7 @@ async def transcribe_file(file_url: str) -> str: task_input = { 'input': get_file_id_from_file_url(file_url), } - task_output = await run_task(nc, "core:audio2text", task_input).output + task_output = (await run_task(nc, "core:audio2text", task_input)).output return task_output['output'] return [ diff --git a/ex_app/lib/all_tools/context_chat.py b/ex_app/lib/all_tools/context_chat.py index c1d8e2a..a8e5105 100644 --- a/ex_app/lib/all_tools/context_chat.py +++ b/ex_app/lib/all_tools/context_chat.py @@ -24,7 +24,7 @@ async def ask_context_chat(question: str) -> str: 'scopeList': [], 'scopeListMeta': '', } - task_output = await 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 [ diff --git a/ex_app/lib/all_tools/deck.py b/ex_app/lib/all_tools/deck.py index 6460d98..31742df 100644 --- a/ex_app/lib/all_tools/deck.py +++ b/ex_app/lib/all_tools/deck.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from langchain_core.tools import tool 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 @@ -37,7 +36,7 @@ async def add_card(board_id: int, stack_id: int, title: str): :return: bool """ - response = 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={ + 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, diff --git a/ex_app/lib/all_tools/files.py b/ex_app/lib/all_tools/files.py index 702a5f2..5f68b62 100644 --- a/ex_app/lib/all_tools/files.py +++ b/ex_app/lib/all_tools/files.py @@ -20,7 +20,7 @@ async def get_file_content(file_path: str): :return: """ - user_id = await nc.ocs('GET', '/ocs/v2.php/cloud/user')["id"] + 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", diff --git a/ex_app/lib/all_tools/lib/task_processing.py b/ex_app/lib/all_tools/lib/task_processing.py index 4f6f917..34d1592 100644 --- a/ex_app/lib/all_tools/lib/task_processing.py +++ b/ex_app/lib/all_tools/lib/task_processing.py @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later import asyncio -import time import typing from niquests import ConnectionError, Timeout diff --git a/ex_app/lib/all_tools/mail.py b/ex_app/lib/all_tools/mail.py index bd53249..d8e1ac0 100644 --- a/ex_app/lib/all_tools/mail.py +++ b/ex_app/lib/all_tools/mail.py @@ -40,7 +40,7 @@ async def send_email(subject: str, body: str, account_id: int, from_email: str, ) as e: await log(nc, LogLvl.DEBUG, "Ignored error during task polling") i += 1 - sleep(1) + await sleep(1) continue @tool @@ -67,7 +67,7 @@ def get_category_name(): async def is_available(nc: AsyncNextcloudApp): try: - res = await 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/openproject.py b/ex_app/lib/all_tools/openproject.py index ec8bcf8..aaf4b2a 100644 --- a/ex_app/lib/all_tools/openproject.py +++ b/ex_app/lib/all_tools/openproject.py @@ -76,7 +76,7 @@ async def create_work_package(project_id: int, title: str, description: Optional "raw": descrption_with_ai_note, } - response = await 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 diff --git a/ex_app/lib/logger.py b/ex_app/lib/logger.py index ad18fd4..bdf23c9 100644 --- a/ex_app/lib/logger.py +++ b/ex_app/lib/logger.py @@ -11,5 +11,7 @@ async def log(nc, level, content): logger.log((level+1)*10, content) try: await nc.log(level, content) - except: + 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 b9c3a23..93d74f6 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -151,10 +151,6 @@ async def background_thread_task(): await log(nc, LogLvl.WARNING, "Error fetching the next task " + tb_str) await wait_for_task(5) continue - except RequestException as e: - await log(nc, LogLvl.DEBUG, "Ignored error during task polling") - await wait_for_task(2) - continue task = response["task"] await log(nc, LogLvl.INFO, 'New Task incoming') diff --git a/ex_app/lib/nc_model.py b/ex_app/lib/nc_model.py index 62d791e..cc060f9 100644 --- a/ex_app/lib/nc_model.py +++ b/ex_app/lib/nc_model.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import asyncio import json -import time import typing from typing import Optional, Any, Sequence, Union, Callable @@ -14,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, AsyncNextcloudApp, NextcloudException +from nc_py_api import AsyncNextcloudApp, NextcloudException from nc_py_api.ex_app import LogLvl from pydantic import BaseModel, ValidationError @@ -113,6 +112,9 @@ async def _agenerate( await asyncio.sleep(1) continue + if i >= 20: + raise Exception("Failed to schedule task") + try: task = Response.model_validate(response).task await log(nc, LogLvl.DEBUG, task) diff --git a/ex_app/lib/tools.py b/ex_app/lib/tools.py index f6e704e..0c013f6 100644 --- a/ex_app/lib/tools.py +++ b/ex_app/lib/tools.py @@ -38,10 +38,11 @@ async def get_tools(nc: AsyncNextcloudApp): 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, 'coroutine') and 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.coroutine, 'safe') or not tool.coroutine.safe) or (not hasattr(tool.func, 'safe') or not tool.coroutine.safe): + if getattr(tool_action, 'safe', False): dangerous_tools.append(tool) # MCP tools cannot be decorated and should always be dangerous else: safe_tools.append(tool) From a463b1908cd992a023bb533feb70e002c75bb1d9 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 10 Feb 2026 09:32:34 +0100 Subject: [PATCH 06/16] fix(timed_memoize): get user by awaiting coroutine correctly Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/lib/decorator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From f92e4d450f80e3b6df3baf3266cda837f55b9481 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 10 Feb 2026 09:44:17 +0100 Subject: [PATCH 07/16] fix: Address review comments Signed-off-by: Marcel Klehr --- ex_app/lib/mcp_server.py | 8 ++++---- ex_app/lib/tools.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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/tools.py b/ex_app/lib/tools.py index 0c013f6..752b941 100644 --- a/ex_app/lib/tools.py +++ b/ex_app/lib/tools.py @@ -28,7 +28,7 @@ async def get_tools(nc: AsyncNextcloudApp): 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 await available_from_import(nc): @@ -42,7 +42,7 @@ async def get_tools(nc: AsyncNextcloudApp): if tool_action is None: safe_tools.append(tool) continue - if getattr(tool_action, 'safe', False): + 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) From 0a90d67f95ec6b2257c447b4ae3aae4fec7fd95f Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 10 Feb 2026 11:06:15 +0100 Subject: [PATCH 08/16] fix(calendar): Make calendar tools non-blocking Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/calendar.py | 210 +++++++++++++++++-------------- 1 file changed, 114 insertions(+), 96 deletions(-) diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index af7a394..21296c7 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -1,6 +1,7 @@ # 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 typing import Optional @@ -22,35 +23,21 @@ async def get_tools(nc: AsyncNextcloudApp): ncSync = NextcloudApp() ncSync.set_user(await nc.user) - @tool - @safe_tool - async def list_calendars(): - """ - List all existing calendars by name - :return: a comma-separated list of calendar names - """ + def list_calendars_sync(): principal = ncSync.cal.principal() calendars = principal.calendars() return ", ".join([cal.name for cal in calendars]) @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]): + @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") @@ -90,15 +77,15 @@ async def schedule_event(calendar_name: str, title: str, description: str, start i = 0 while i < 20: try: - json = await nc.ocs('GET', '/ocs/v2.php/cloud/user') + json = ncSync.ocs('GET', '/ocs/v2.php/cloud/user') break except ( ConnectionError, Timeout ) as e: - await log(nc, LogLvl.DEBUG, "Ignored error during task polling") + log(ncSync, LogLvl.DEBUG, "Ignored error during task polling") i += 1 - await asyncio.sleep(1) + time.sleep(1) continue # ...and set the organizer @@ -112,8 +99,89 @@ async def schedule_event(calendar_name: str, title: str, description: str, start calendar = {cal.name: cal for cal in calendars}[calendar_name] calendar.add_event(str(c)) + @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]): + """ + 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) + return True + 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') + + 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") + + freebusyRequest = """ + BEGIN:VCALENDAR + PRODID:-//IDN nextcloud.com//Calendar app 5.1.0-beta.2//EN + CALSCALE:GREGORIAN + VERSION:2.0 + METHOD:REQUEST + BEGIN:VFREEBUSY + DTSTAMP:20250131T123029Z + UID:03c8f220-d313-4c86-ae06-19fbae157079 + DTSTART:{DTSTART} + DTEND:{DTEND} + {ATTENDEES}END:VFREEBUSY + END:VCALENDAR + """.replace('{ATTENDEES}', attendees).replace('{DTSTART}', dtstart).replace('{DTEND}', dtend) + 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 @safe_tool @@ -126,79 +194,11 @@ async def find_free_time_slot_in_calendar(participants: list[str], slot_duration :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: """ - - me = await nc.ocs('GET', '/ocs/v2.php/cloud/user') - - 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") - - freebusyRequest = """ -BEGIN:VCALENDAR -PRODID:-//IDN nextcloud.com//Calendar app 5.1.0-beta.2//EN -CALSCALE:GREGORIAN -VERSION:2.0 -METHOD:REQUEST -BEGIN:VFREEBUSY -DTSTAMP:20250131T123029Z -UID:03c8f220-d313-4c86-ae06-19fbae157079 -DTSTART:{DTSTART} -DTEND:{DTEND} -{ATTENDEES}END:VFREEBUSY -END:VCALENDAR -""".replace('{ATTENDEES}', attendees).replace('{DTSTART}', dtstart).replace('{DTEND}', dtend) - username = nc._session.user - response = await 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]) - available_slots = find_available_slots(start_time, end_time, busy_times, timedelta(hours=slot_duration)) + available_slots = asyncio.to_thread(find_free_time_slot_in_calendar_sync, participants, slot_duration, start_time, end_time) return available_slots - - @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 - """ - + 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,7 +225,7 @@ async def add_task(calendar_name: str, title: str, description: str, due_date: O due_datetime = tz.localize(due_datetime) t.due = due_datetime - + # Add event to calendar c.todos.add(t) @@ -236,6 +236,24 @@ async def add_task(calendar_name: str, title: str, description: str, due_date: O 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, From 281a20b3c62c64c1f2eb6a696eabd4e32b969e97 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 10 Feb 2026 11:06:47 +0100 Subject: [PATCH 09/16] fix: address review comments Signed-off-by: Marcel Klehr --- ex_app/lib/all_tools/calendar.py | 26 +++++++++++++------------- ex_app/lib/main.py | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ex_app/lib/all_tools/calendar.py b/ex_app/lib/all_tools/calendar.py index 21296c7..3dc9775 100644 --- a/ex_app/lib/all_tools/calendar.py +++ b/ex_app/lib/all_tools/calendar.py @@ -146,19 +146,19 @@ def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration: dtend = end_time.strftime("%Y%m%dT%H%M%SZ") freebusyRequest = """ - BEGIN:VCALENDAR - PRODID:-//IDN nextcloud.com//Calendar app 5.1.0-beta.2//EN - CALSCALE:GREGORIAN - VERSION:2.0 - METHOD:REQUEST - BEGIN:VFREEBUSY - DTSTAMP:20250131T123029Z - UID:03c8f220-d313-4c86-ae06-19fbae157079 - DTSTART:{DTSTART} - DTEND:{DTEND} - {ATTENDEES}END:VFREEBUSY - END:VCALENDAR - """.replace('{ATTENDEES}', attendees).replace('{DTSTART}', dtstart).replace('{DTEND}', dtend) +BEGIN:VCALENDAR +PRODID:-//IDN nextcloud.com//Calendar app 5.1.0-beta.2//EN +CALSCALE:GREGORIAN +VERSION:2.0 +METHOD:REQUEST +BEGIN:VFREEBUSY +DTSTAMP:20250131T123029Z +UID:03c8f220-d313-4c86-ae06-19fbae157079 +DTSTART:{DTSTART} +DTEND:{DTEND} +{ATTENDEES}END:VFREEBUSY +END:VCALENDAR +""".replace('{ATTENDEES}', attendees).replace('{DTSTART}', dtstart).replace('{DTEND}', dtend) username = ncSync._session.user response = ncSync._session._create_adapter(True).request('POST', f"{nc.app_cfg.endpoint}/remote.php/dav/calendars/{username}/outbox/", diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 93d74f6..0d5d8e3 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -42,7 +42,7 @@ app_enabled = Event() TRIGGER = Event() IDLE_POLLING_INTERVAL = 5 -IDLE_POLLING_INTERVAL_WITH_TRIGGER = 5 * 60 +IDLE_POLLING_INTERVAL_WITH_TRIGGER = 5 LOCALE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "locale") current_translator = ContextVar("current_translator") From 9af27ee958ef902062b5ce74c3887f4ea181cf05 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 11 Feb 2026 09:51:59 +0100 Subject: [PATCH 10/16] fix(trigger mechanism): Track running tasks and keep long poll interval if no tasks are running Signed-off-by: Marcel Klehr --- ex_app/lib/main.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 0d5d8e3..bc40396 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -42,7 +42,7 @@ app_enabled = Event() TRIGGER = Event() IDLE_POLLING_INTERVAL = 5 -IDLE_POLLING_INTERVAL_WITH_TRIGGER = 5 +IDLE_POLLING_INTERVAL_WITH_TRIGGER = 5 * 60 LOCALE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "locale") current_translator = ContextVar("current_translator") @@ -144,7 +144,12 @@ async def background_thread_task(): try: response = await nc.providers.task_processing.next_task([provider.id], [provider.task_type]) if not response or not 'task' in response: - await wait_for_task() + 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)) @@ -158,9 +163,12 @@ async def background_thread_task(): await 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)) +NUM_RUNNING_TASKS = 0 async def handle_task(task, nc: AsyncNextcloudApp): + global NUM_RUNNING_TASKS try: + NUM_RUNNING_TASKS += 1 nextcloud = AsyncNextcloudApp() if task['userId']: await nextcloud.set_user(task['userId']) @@ -173,6 +181,8 @@ async def handle_task(task, nc: AsyncNextcloudApp): except (NextcloudException, RequestException) as net_err: tb_str = ''.join(traceback.format_exception(net_err)) await log(nc, LogLvl.WARNING, "Network error in reporting the error: " + tb_str) + finally: + NUM_RUNNING_TASKS -= 1 return try: await nc.providers.task_processing.report_result( @@ -182,6 +192,8 @@ async def handle_task(task, nc: AsyncNextcloudApp): except (NextcloudException, RequestException, JSONDecodeError) as e: tb_str = ''.join(traceback.format_exception(e)) await log(nc, LogLvl.ERROR, "Network error trying to report the task result: " + tb_str) + finally: + NUM_RUNNING_TASKS -= 1 From 7bf1bd38ed5aeb2dda60108213580fe88b9e589c Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 11 Feb 2026 16:10:15 +0100 Subject: [PATCH 11/16] fix(backgroundthread): Add TaskGroup to keep reference to tasks Signed-off-by: Marcel Klehr --- ex_app/lib/main.py | 51 +++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index bc40396..9b67bae 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -136,32 +136,33 @@ async def enabled_handler(enabled: bool, nc: AsyncNextcloudApp) -> str: async def background_thread_task(): 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 = 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) + 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)) - await log(nc, LogLvl.WARNING, "Error fetching the next task " + tb_str) - await wait_for_task(5) - continue - 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)})) - asyncio.create_task(handle_task(task, nc)) + 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 @@ -174,9 +175,9 @@ async def handle_task(task, nc: AsyncNextcloudApp): await nextcloud.set_user(task['userId']) output = await react(task, nextcloud) except Exception as e: # noqa - tb_str = ''.join(traceback.format_exception(e)) - await log(nc, LogLvl.ERROR, "Error: " + tb_str) try: + 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)) From a4a549702708c93666b92ea756bd5ab18f46a4ea Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 12 Feb 2026 14:42:57 +0100 Subject: [PATCH 12/16] fix(ci): Upgrade to python 3.11 Signed-off-by: Marcel Klehr --- .github/workflows/integration_test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From f5d6e0082249c0ad5226893c0472aaf654ca621f Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 12 Feb 2026 14:48:14 +0100 Subject: [PATCH 13/16] fix(pyproject.toml): Upgrade to python 3.11 Signed-off-by: Marcel Klehr --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8e90793..58574f4 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.1" 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 From 9d53894a4549ceb217e227262baab2185be14d5a Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 12 Feb 2026 15:27:42 +0100 Subject: [PATCH 14/16] fix(TaskGroup): Use task group correctly Signed-off-by: Marcel Klehr --- ex_app/lib/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 9b67bae..5f87dd3 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -136,7 +136,7 @@ async def enabled_handler(enabled: bool, nc: AsyncNextcloudApp) -> str: async def background_thread_task(): nc = AsyncNextcloudApp() - async with asyncio.TaskGroup as tg: + async with asyncio.TaskGroup() as tg: while True: if not app_enabled.is_set(): await asyncio.sleep(5) From 379728c64925b787adfabfde01d1b33161ef0729 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 12 Feb 2026 15:29:10 +0100 Subject: [PATCH 15/16] chore: Add comment Signed-off-by: Marcel Klehr --- ex_app/lib/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 5f87dd3..8a1abcd 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -204,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() From 9080d026bdba4a358c6730eaf6d133750dbcfbf9 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 12 Feb 2026 15:54:59 +0100 Subject: [PATCH 16/16] chore: Update pyproject.toml Signed-off-by: Marcel Klehr --- poetry.lock | 33 ++++++--------------------------- pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 28 deletions(-) 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 58574f4..35a87b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" package-mode = false [tool.poetry.dependencies] -python = "^3.1" +python = ">=3.11,<4" nc-py-api = {extras = ["calendar"], version = "^0.22.0"} langgraph = "1.*" langchain = "^0.3.25"