From d65f6156f651203e186f04500db38fc86f732c58 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Wed, 11 Nov 2026 14:50:22 +0530 Subject: [PATCH 1/5] fix(context_chat): expose providers in the system prompt Signed-off-by: Anupam Kumar --- ex_app/lib/agent.py | 7 ++- ex_app/lib/all_tools/context_chat.py | 71 +++++++++++++++++++--------- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index 80141d1..2a1682f 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -6,13 +6,13 @@ from time import monotonic from typing import Any, cast +import pytz from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage, ToolMessage from langchain_core.runnables import RunnableConfig from nc_py_api import AsyncNextcloudApp from nc_py_api.ex_app import persistent_storage -import pytz - +from ex_app.lib.all_tools.context_chat import get_context_chat_providers from ex_app.lib.all_tools.lib.tz import user_timezone_async from ex_app.lib.all_tools.nextcloud_links import get_absolute_base_url from ex_app.lib.all_tools.skills import list_skills_metadata @@ -197,6 +197,9 @@ async def call_model( system_prompt_text += "Always check for the mail account id before requesting a folder list.\n" if tool_enabled("web_fetch"): system_prompt_text += "Use the web_fetch tool to fetch web content. You can fetch the complete page content of a duckduckgo search result using web_fetch as well.\n" + if tool_enabled("ask_context_chat"): + cc_providers = await get_context_chat_providers(nc, pretty=True) + system_prompt_text += f"Available providers in Context Chat are: {cc_providers}. Use these in the Context Chat tools to scope the search to specific types of documents." if tool_enabled("parse_nextcloud_url"): # app_cfg.endpoint can be an instance-internal address, so it is only a last resort base_url = await get_absolute_base_url(nc) or nc.app_cfg.endpoint.rstrip('/') diff --git a/ex_app/lib/all_tools/context_chat.py b/ex_app/lib/all_tools/context_chat.py index 4b4ef27..77cec89 100644 --- a/ex_app/lib/all_tools/context_chat.py +++ b/ex_app/lib/all_tools/context_chat.py @@ -1,30 +1,47 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later import json +import logging from typing import Literal + +import pydantic from langchain_core.tools import tool 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 timed_memoize from ex_app.lib.all_tools.lib.impulse import ImpulseRadius, impulse, read_only +from ex_app.lib.all_tools.lib.task_processing import run_task +LOGGER = logging.getLogger(__name__) +CC_PROVIDERS_TTL = 10 * 60 # 10 mins -async def get_tools(nc: AsyncNextcloudApp): - @tool - @impulse(ImpulseRadius.SELF) - @read_only - async def list_context_chat_providers() -> str: - """ - List the content providers available to context chat (e.g., files, mail). - Use this to discover valid provider keys for ask_context_chat's scope_list when scope_type is "provider". Note that the files__default provider is always available. - :return: JSON array of available providers - """ - response = await nc._session._create_adapter(False).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/context_chat/providers", headers={ - "Content-Type": "application/json", - "OCS-APIREQUEST": "true", - }) - return json.dumps(response.json()) +class CcProvider(pydantic.BaseModel): + id: str + label: str + icon: str + + +@timed_memoize(CC_PROVIDERS_TTL) +async def get_context_chat_providers(nc: AsyncNextcloudApp, pretty: bool = False) -> list[CcProvider] | list[str]: + response = await nc._session._create_adapter(False).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/context_chat/providers", headers={ + "Content-Type": "application/json", + "OCS-APIREQUEST": "true", + }) + + try: + result: list[CcProvider] = pydantic.TypeAdapter(list[CcProvider]).validate_python(response) + except Exception as e: + LOGGER.warning(f"Validation of context_chat providers response failed: {e}", exc_info=e) + return [] + + if not pretty: + return result + + return [f"{p.label}({p.id})" for p in result] + + +async def get_tools(nc: AsyncNextcloudApp): @tool @impulse(ImpulseRadius.SELF) @@ -51,18 +68,28 @@ async def ask_context_chat( :return: the answer from context chat """ + if scope_type != "none" and not scope_list: + raise RuntimeError("A set scope requires at least one item in the scope list") + + if scope_type == "provider": + available_providers = await get_context_chat_providers(nc) + # ignore None's + provider_keys = {p.get("id") for p in available_providers} + for scope in scope_list: + if scope not in provider_keys: + raise RuntimeError(f"Provider {scope} is not an available provider. Available providers: {provider_keys}") + task_input = { - 'prompt': question, - 'scopeType': scope_type, - 'scopeList': scope_list or [], - 'scopeListMeta': '', + "prompt": question, + "scopeType": scope_type, + "scopeList": scope_list or [], + "scopeListMeta": "", } task_output = (await run_task(nc, "context_chat:context_chat", task_input)).output - return task_output['output'] + return task_output["output"] return [ ask_context_chat, - list_context_chat_providers, ] def get_category_name(): From e83230bf2499478f2553489760476ca037f9bd5c Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Wed, 11 Nov 2026 16:02:53 +0530 Subject: [PATCH 2/5] feat(context_chat): add context chat search tool + fixes Signed-off-by: Anupam Kumar --- ex_app/lib/agent.py | 4 +- ex_app/lib/all_tools/context_chat.py | 81 ++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index 2a1682f..0d7ebb3 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -198,8 +198,8 @@ async def call_model( if tool_enabled("web_fetch"): system_prompt_text += "Use the web_fetch tool to fetch web content. You can fetch the complete page content of a duckduckgo search result using web_fetch as well.\n" if tool_enabled("ask_context_chat"): - cc_providers = await get_context_chat_providers(nc, pretty=True) - system_prompt_text += f"Available providers in Context Chat are: {cc_providers}. Use these in the Context Chat tools to scope the search to specific types of documents." + cc_providers = await get_context_chat_providers(nc, True) + system_prompt_text += f"Available providers in Context Chat are: {cc_providers}. Use these in the Context Chat tools to scope the search to specific types of documents. It is passed in the scope_list when scope_type is \"provider\"." if tool_enabled("parse_nextcloud_url"): # app_cfg.endpoint can be an instance-internal address, so it is only a last resort base_url = await get_absolute_base_url(nc) or nc.app_cfg.endpoint.rstrip('/') diff --git a/ex_app/lib/all_tools/context_chat.py b/ex_app/lib/all_tools/context_chat.py index 77cec89..c01d6bd 100644 --- a/ex_app/lib/all_tools/context_chat.py +++ b/ex_app/lib/all_tools/context_chat.py @@ -13,7 +13,9 @@ from ex_app.lib.all_tools.lib.task_processing import run_task LOGGER = logging.getLogger(__name__) -CC_PROVIDERS_TTL = 10 * 60 # 10 mins +CC_PROVIDERS_TTL = 60 * 60 # 1h +CONTEXT_CHAT_TASK_TYPE = "context_chat:context_chat" +CONTEXT_CHAT_SEARCH_TASK_TYPE = "context_chat:context_chat_search" class CcProvider(pydantic.BaseModel): @@ -30,7 +32,7 @@ async def get_context_chat_providers(nc: AsyncNextcloudApp, pretty: bool = False }) try: - result: list[CcProvider] = pydantic.TypeAdapter(list[CcProvider]).validate_python(response) + result: list[CcProvider] = pydantic.TypeAdapter(list[CcProvider]).validate_python(response.json()) except Exception as e: LOGGER.warning(f"Validation of context_chat providers response failed: {e}", exc_info=e) return [] @@ -41,8 +43,27 @@ async def get_context_chat_providers(nc: AsyncNextcloudApp, pretty: bool = False return [f"{p.label}({p.id})" for p in result] +async def _check_provider_scope_valid(nc: AsyncNextcloudApp, scope_list: list[str]): + available_providers = await get_context_chat_providers(nc) + provider_keys = {p.id for p in available_providers} + for scope in scope_list: + if scope not in provider_keys: + raise RuntimeError(f"Provider {scope} is not an available provider. Available providers: {provider_keys}") + + async def get_tools(nc: AsyncNextcloudApp): + @tool + @impulse(ImpulseRadius.SELF) + @read_only + async def list_context_chat_providers() -> list[str]: + """ + Refreshes the content providers list available to context chat (e.g., files, mail). + :return: list of available providers + """ + await get_context_chat_providers.cache_invalidate(nc) + return await get_context_chat_providers(nc, True) + @tool @impulse(ImpulseRadius.SELF) @read_only @@ -72,12 +93,7 @@ async def ask_context_chat( raise RuntimeError("A set scope requires at least one item in the scope list") if scope_type == "provider": - available_providers = await get_context_chat_providers(nc) - # ignore None's - provider_keys = {p.get("id") for p in available_providers} - for scope in scope_list: - if scope not in provider_keys: - raise RuntimeError(f"Provider {scope} is not an available provider. Available providers: {provider_keys}") + await _check_provider_scope_valid(nc, scope_list) task_input = { "prompt": question, @@ -85,11 +101,56 @@ async def ask_context_chat( "scopeList": scope_list or [], "scopeListMeta": "", } - task_output = (await run_task(nc, "context_chat:context_chat", task_input)).output + task_output = (await run_task(nc, CONTEXT_CHAT_TASK_TYPE, task_input)).output return task_output["output"] + @tool + @impulse(ImpulseRadius.SELF) + @read_only + async def search_context_chat( + prompt: str, + scope_type: Literal['none', 'source', 'provider'] = 'none', + scope_list: list[str] | None = None, + limit: int = 30, + ) -> str: + """ + Search the context chat database for the user's documents. It uses semantic search to find similar documents. + This is for only searching the documents without an LLM explanation. + :param prompt: The text to find similar documents for + :param scope_type: Optional. Restricts which documents the oracle searches. + - "none" (default): search across all of the user's documents and content providers. + - "source": restrict the search to specific items listed in scope_list. + - "provider": restrict the search to specific content providers listed in scope_list. + :param scope_list: Required when scope_type is "source" or "provider"; ignored otherwise. + - For scope_type "source": a list of source IDs in the format "__: " + (e.g., "files__default: 12345" for the file with Nextcloud file id 12345). File ids can be + obtained from the file-related tools or unified search. + - For scope_type "provider": a list of provider keys in the format "__" + (e.g., "files__default" for files, "mail__messages" for mail). + :param limit: The max no. of documents to return, default 30. + :return: the answer from context chat + """ + + if scope_type != "none" and not scope_list: + raise RuntimeError("A set scope requires at least one item in the scope list") + + if scope_type == "provider": + await _check_provider_scope_valid(nc, scope_list) + + task_input = { + "prompt": prompt, + "scopeType": scope_type, + "scopeList": scope_list or [], + "scopeListMeta": "", + "limit": limit, + } + task_output = (await run_task(nc, CONTEXT_CHAT_SEARCH_TASK_TYPE, task_input)).output + return task_output["sources"] + return [ + list_context_chat_providers, ask_context_chat, + search_context_chat, ] def get_category_name(): @@ -97,4 +158,4 @@ def get_category_name(): 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 + return CONTEXT_CHAT_TASK_TYPE in tasktypes \ No newline at end of file From b76b3cf58dd1e5f2962b0ab6e6ab355fc48ece79 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Wed, 11 Nov 2026 16:18:08 +0530 Subject: [PATCH 3/5] fix(context_chat): cache un'pretty'ly Signed-off-by: Anupam Kumar --- ex_app/lib/all_tools/context_chat.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ex_app/lib/all_tools/context_chat.py b/ex_app/lib/all_tools/context_chat.py index c01d6bd..4a60036 100644 --- a/ex_app/lib/all_tools/context_chat.py +++ b/ex_app/lib/all_tools/context_chat.py @@ -25,18 +25,22 @@ class CcProvider(pydantic.BaseModel): @timed_memoize(CC_PROVIDERS_TTL) -async def get_context_chat_providers(nc: AsyncNextcloudApp, pretty: bool = False) -> list[CcProvider] | list[str]: +async def _fetch_context_chat_providers(nc: AsyncNextcloudApp) -> list[CcProvider]: response = await nc._session._create_adapter(False).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/context_chat/providers", headers={ "Content-Type": "application/json", "OCS-APIREQUEST": "true", }) try: - result: list[CcProvider] = pydantic.TypeAdapter(list[CcProvider]).validate_python(response.json()) + return pydantic.TypeAdapter(list[CcProvider]).validate_python(response.json()) except Exception as e: LOGGER.warning(f"Validation of context_chat providers response failed: {e}", exc_info=e) return [] + +async def get_context_chat_providers(nc: AsyncNextcloudApp, pretty: bool = False) -> list[CcProvider] | list[str]: + result = await _fetch_context_chat_providers(nc) + if not pretty: return result @@ -61,7 +65,7 @@ async def list_context_chat_providers() -> list[str]: Refreshes the content providers list available to context chat (e.g., files, mail). :return: list of available providers """ - await get_context_chat_providers.cache_invalidate(nc) + await _fetch_context_chat_providers.cache_invalidate(nc) return await get_context_chat_providers(nc, True) @tool From 7a703065cd7aa5972002834668e9a7a03b20a4ab Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Wed, 11 Nov 2026 16:24:34 +0530 Subject: [PATCH 4/5] fix: bound limit to 200 max docs Signed-off-by: Anupam Kumar --- ex_app/lib/all_tools/context_chat.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ex_app/lib/all_tools/context_chat.py b/ex_app/lib/all_tools/context_chat.py index 4a60036..569026e 100644 --- a/ex_app/lib/all_tools/context_chat.py +++ b/ex_app/lib/all_tools/context_chat.py @@ -131,7 +131,7 @@ async def search_context_chat( obtained from the file-related tools or unified search. - For scope_type "provider": a list of provider keys in the format "__" (e.g., "files__default" for files, "mail__messages" for mail). - :param limit: The max no. of documents to return, default 30. + :param limit: The max no. of documents to return, default 30, max 200. :return: the answer from context chat """ @@ -146,7 +146,7 @@ async def search_context_chat( "scopeType": scope_type, "scopeList": scope_list or [], "scopeListMeta": "", - "limit": limit, + "limit": min(200, max(1, limit)), } task_output = (await run_task(nc, CONTEXT_CHAT_SEARCH_TASK_TYPE, task_input)).output return task_output["sources"] From f0f05e77afb992e9ae03a7a0eb375576ae92e38c Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Thu, 12 Nov 2026 16:35:55 +0530 Subject: [PATCH 5/5] fix(context_chat): cache errors + label return types correctly Signed-off-by: Anupam Kumar --- ex_app/lib/agent.py | 3 ++- ex_app/lib/all_tools/context_chat.py | 23 ++++++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/ex_app/lib/agent.py b/ex_app/lib/agent.py index 0d7ebb3..6fcaaa4 100644 --- a/ex_app/lib/agent.py +++ b/ex_app/lib/agent.py @@ -199,7 +199,8 @@ async def call_model( system_prompt_text += "Use the web_fetch tool to fetch web content. You can fetch the complete page content of a duckduckgo search result using web_fetch as well.\n" if tool_enabled("ask_context_chat"): cc_providers = await get_context_chat_providers(nc, True) - system_prompt_text += f"Available providers in Context Chat are: {cc_providers}. Use these in the Context Chat tools to scope the search to specific types of documents. It is passed in the scope_list when scope_type is \"provider\"." + if len(cc_providers) > 0: + system_prompt_text += f"Available providers in Context Chat are: {cc_providers}. Use these in the Context Chat tools to scope the search to specific types of documents. It is passed in the scope_list when scope_type is \"provider\"." if tool_enabled("parse_nextcloud_url"): # app_cfg.endpoint can be an instance-internal address, so it is only a last resort base_url = await get_absolute_base_url(nc) or nc.app_cfg.endpoint.rstrip('/') diff --git a/ex_app/lib/all_tools/context_chat.py b/ex_app/lib/all_tools/context_chat.py index 569026e..32b8740 100644 --- a/ex_app/lib/all_tools/context_chat.py +++ b/ex_app/lib/all_tools/context_chat.py @@ -26,10 +26,14 @@ class CcProvider(pydantic.BaseModel): @timed_memoize(CC_PROVIDERS_TTL) async def _fetch_context_chat_providers(nc: AsyncNextcloudApp) -> list[CcProvider]: - response = await nc._session._create_adapter(False).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/context_chat/providers", headers={ - "Content-Type": "application/json", - "OCS-APIREQUEST": "true", - }) + try: + response = await nc._session._create_adapter(False).request('GET', f"{nc.app_cfg.endpoint}/index.php/apps/context_chat/providers", headers={ + "Content-Type": "application/json", + "OCS-APIREQUEST": "true", + }) + except Exception as e: + LOGGER.warning(f"Network failure in fetching context chat content providers: {e}", exc_info=e) + return [] try: return pydantic.TypeAdapter(list[CcProvider]).validate_python(response.json()) @@ -75,7 +79,7 @@ async def ask_context_chat( question: str, scope_type: Literal['none', 'source', 'provider'] = 'none', scope_list: list[str] | None = None, - ) -> str: + ) -> dict[str, str | list[str]]: """ Ask the context chat oracle a question about the user's documents. It knows the contents of all of the users documents. This is often easier than searching for documents and then fetching their contents when trying to answer questions. @@ -90,7 +94,7 @@ async def ask_context_chat( obtained from the file-related tools or unified search. - For scope_type "provider": a list of provider keys in the format "__" (e.g., "files__default" for files, "mail__messages" for mail). - :return: the answer from context chat + :return: the answer from context chat along with the sources used to get to it. Source icons can be displayed in the UI if presented as markdown images. """ if scope_type != "none" and not scope_list: @@ -106,7 +110,7 @@ async def ask_context_chat( "scopeListMeta": "", } task_output = (await run_task(nc, CONTEXT_CHAT_TASK_TYPE, task_input)).output - return task_output["output"] + return task_output @tool @impulse(ImpulseRadius.SELF) @@ -116,7 +120,7 @@ async def search_context_chat( scope_type: Literal['none', 'source', 'provider'] = 'none', scope_list: list[str] | None = None, limit: int = 30, - ) -> str: + ) -> list[str]: """ Search the context chat database for the user's documents. It uses semantic search to find similar documents. This is for only searching the documents without an LLM explanation. @@ -132,7 +136,8 @@ async def search_context_chat( - For scope_type "provider": a list of provider keys in the format "__" (e.g., "files__default" for files, "mail__messages" for mail). :param limit: The max no. of documents to return, default 30, max 200. - :return: the answer from context chat + :return: A list of JSON strings of sources containing the id, label, icon and an absolute URL to the source documents. + Icons can be displayed in the UI if presented as markdown images. """ if scope_type != "none" and not scope_list: