diff --git a/ex_app/lib/all_tools/lib/task_processing.py b/ex_app/lib/all_tools/lib/task_processing.py index f762583..c315333 100644 --- a/ex_app/lib/all_tools/lib/task_processing.py +++ b/ex_app/lib/all_tools/lib/task_processing.py @@ -8,6 +8,7 @@ from niquests import ConnectionError, Timeout from pydantic import BaseModel, ValidationError +from ex_app.lib.errors import UserFacingError from ex_app.lib.logger import log @@ -15,6 +16,8 @@ class Task(BaseModel): id: int status: str output: dict[str, typing.Any] | None = None + # Serialized by Nextcloud 33+; the admin-facing `errorMessage` is never exposed over OCS + userFacingErrorMessage: str | None = None class Response(BaseModel): task: Task @@ -77,7 +80,12 @@ async def run_task(nc: AsyncNextcloudApp, type, task_input): except ValidationError as e: raise Exception("Failed to parse Nextcloud TaskProcessing task result") from e if task.status != "STATUS_SUCCESSFUL": - raise Exception("Nextcloud TaskProcessing Task failed") + if task.userFacingErrorMessage: + raise UserFacingError( + f"Nextcloud TaskProcessing task of type {type} failed: {task.userFacingErrorMessage}", + task.userFacingErrorMessage, + ) + raise UserFacingError(f"Nextcloud TaskProcessing task of type {type} failed") if not isinstance(task.output, dict) or all(x not in ACCEPTED_OUTPUT_KEYS for x in task.output): raise Exception(f'Expected one of {ACCEPTED_OUTPUT_KEYS} in Nextcloud TaskProcessing task result') diff --git a/ex_app/lib/errors.py b/ex_app/lib/errors.py new file mode 100644 index 0000000..ea2a347 --- /dev/null +++ b/ex_app/lib/errors.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + + +class UserFacingError(Exception): + """An error carrying a message that is safe to show to the end user. + + Nextcloud TaskProcessing keeps two error strings per task: `errorMessage` for + admins/logs and `userFacingErrorMessage` for the user. Sub-tasks we schedule + (text2text chat, image generation, …) only expose the latter over the OCS API, + so we carry it along and report it on our own task as well. + """ + + def __init__(self, message: str, user_facing_message: str | None = None): + super().__init__(message) + self.user_facing_message = user_facing_message diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 591f1f2..0a23629 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -22,6 +22,7 @@ SettingsFieldType) from ex_app.lib.agent import react +from ex_app.lib.errors import UserFacingError from ex_app.lib.logger import log from ex_app.lib.mcp_server import UserAuthMiddleware, ToolListMiddleware from ex_app.lib.provider import provider, multimodal_provider @@ -181,6 +182,16 @@ async def background_thread_task(): NUM_RUNNING_TASKS_LOCK = asyncio.Lock() NUM_RUNNING_TASKS = 0 +async def report_error(nc: AsyncNextcloudApp, task_id: int, e: Exception): + """Report a failed task, passing on the user-facing error message when we have one.""" + # The user-facing message is only picked up by Nextcloud 33+, older versions ignore it. + await nc.providers.task_processing.report_result( + task_id, + error_message=str(e), + user_facing_error_message=e.user_facing_message if isinstance(e, UserFacingError) else None, + ) + + async def handle_task(task, nc: AsyncNextcloudApp): global NUM_RUNNING_TASKS try: @@ -213,7 +224,7 @@ async def stream_output(intermediate_output): 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)) + await report_error(nc, task["id"], e) 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) diff --git a/ex_app/lib/nc_model.py b/ex_app/lib/nc_model.py index 76915f5..98bc03b 100644 --- a/ex_app/lib/nc_model.py +++ b/ex_app/lib/nc_model.py @@ -21,6 +21,7 @@ from langchain_core.language_models.chat_models import BaseChatModel +from ex_app.lib.errors import UserFacingError from ex_app.lib.logger import log @@ -73,6 +74,18 @@ class Task(BaseModel): status: str output: dict[str, typing.Any] | None = None preferStreaming: bool | None = None + # Serialized by Nextcloud 33+; the admin-facing `errorMessage` is never exposed over OCS + userFacingErrorMessage: str | None = None + + +def task_failed_error(task: Task) -> UserFacingError: + """Turn a failed sub-task into an error that passes the provider's user-facing message on.""" + if task.userFacingErrorMessage: + return UserFacingError( + f"Nextcloud TaskProcessing Task failed: {task.userFacingErrorMessage}", + task.userFacingErrorMessage, + ) + return UserFacingError("Nextcloud TaskProcessing Task failed") class Response(BaseModel): @@ -334,10 +347,13 @@ async def _agenerate( raise if task.status == "STATUS_FAILED": - raise Exception("Nextcloud TaskProcessing Task failed") + raise task_failed_error(task) if task.status in ("STATUS_RUNNING", "STATUS_SCHEDULED"): - raise Exception("Nextcloud TaskProcessing Task timed out") + raise UserFacingError( + "Nextcloud TaskProcessing Task timed out", + "The language model did not respond in time. Please try again.", + ) message = self._task_to_message(task) return ChatResult(generations=[ChatGeneration(message=message)]) @@ -381,10 +397,13 @@ async def _astream( streamed_output = current_output if task.status == "STATUS_FAILED": - raise Exception("Nextcloud TaskProcessing Task failed") + raise task_failed_error(task) if task.status in ("STATUS_RUNNING", "STATUS_SCHEDULED"): - raise Exception("Nextcloud TaskProcessing Task timed out") + raise UserFacingError( + "Nextcloud TaskProcessing Task timed out", + "The language model did not respond in time. Please try again.", + ) final_output = self._task_output_text(task) if final_output is None: