diff --git a/pyproject.toml b/pyproject.toml index 84eb00578..76b136baf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath-langchain" -version = "0.10.10" +version = "0.10.11" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath>=2.10.57, <2.11.0", "uipath-core>=0.5.13, <0.6.0", - "uipath-platform>=0.1.36, <0.2.0", + "uipath-platform>=0.1.42, <0.2.0", "uipath-runtime>=0.10.0, <0.11.0", "langgraph>=1.1.8, <2.0.0", "langchain-core>=1.2.11, <2.0.0", diff --git a/src/uipath_langchain/agent/tools/context_tool.py b/src/uipath_langchain/agent/tools/context_tool.py index ff5fd128b..062a5426e 100644 --- a/src/uipath_langchain/agent/tools/context_tool.py +++ b/src/uipath_langchain/agent/tools/context_tool.py @@ -248,6 +248,8 @@ async def context_tool_fn( static_folder_path_prefix or _resolved_arg_folder_prefix ) + debug_run = UiPathConfig.is_studio_project + retriever = ContextGroundingRetriever( index_name=resource.index_name, folder_path=get_execution_folder_path(), @@ -255,6 +257,7 @@ async def context_tool_fn( threshold=threshold, scope_folder=resolved_folder_path_prefix, scope_extension=file_extension, + include_system_indexes=debug_run, ) actual_query = prompt or query diff --git a/src/uipath_langchain/retrievers/context_grounding_retriever.py b/src/uipath_langchain/retrievers/context_grounding_retriever.py index 59e359d1e..bdc9d8d8b 100644 --- a/src/uipath_langchain/retrievers/context_grounding_retriever.py +++ b/src/uipath_langchain/retrievers/context_grounding_retriever.py @@ -1,3 +1,5 @@ +import logging + from langchain_core.callbacks import ( AsyncCallbackManagerForRetrieverRun, CallbackManagerForRetrieverRun, @@ -7,6 +9,8 @@ from uipath.platform import UiPath from uipath.platform.context_grounding import SearchMode, UnifiedSearchScope +logger = logging.getLogger(__name__) + class ContextGroundingRetriever(BaseRetriever): index_name: str @@ -17,6 +21,7 @@ class ContextGroundingRetriever(BaseRetriever): threshold: float = 0.0 scope_folder: str | None = None scope_extension: str | None = None + include_system_indexes: bool = False def _build_scope(self) -> UnifiedSearchScope | None: if self.scope_folder or self.scope_extension: @@ -32,6 +37,11 @@ def _get_relevant_documents( """Sync implementation calls context_grounding unified_search API.""" sdk = self.uipath_sdk if self.uipath_sdk is not None else UiPath() + if self.include_system_indexes: + logger.debug( + "Searching index '%s' with system-index fallback enabled", + self.index_name, + ) result = sdk.context_grounding.unified_search( self.index_name, query, @@ -43,6 +53,7 @@ def _get_relevant_documents( scope=self._build_scope(), folder_path=self.folder_path, folder_key=self.folder_key, + include_system_indexes=self.include_system_indexes, ) values = result.semantic_results.values if result.semantic_results else [] @@ -72,6 +83,11 @@ async def _aget_relevant_documents( """Async implementation calls context_grounding unified_search_async API.""" sdk = self.uipath_sdk if self.uipath_sdk is not None else UiPath() + if self.include_system_indexes: + logger.debug( + "Searching index '%s' with system-index fallback enabled", + self.index_name, + ) result = await sdk.context_grounding.unified_search_async( self.index_name, query, @@ -83,6 +99,7 @@ async def _aget_relevant_documents( scope=self._build_scope(), folder_path=self.folder_path, folder_key=self.folder_key, + include_system_indexes=self.include_system_indexes, ) values = result.semantic_results.values if result.semantic_results else [] diff --git a/tests/agent/tools/test_context_tool.py b/tests/agent/tools/test_context_tool.py index 502d39f2d..a3d43b746 100644 --- a/tests/agent/tools/test_context_tool.py +++ b/tests/agent/tools/test_context_tool.py @@ -525,6 +525,48 @@ async def test_semantic_search_uses_execution_folder_path(self, semantic_config) call_kwargs = mock_retriever_class.call_args[1] assert call_kwargs["folder_path"] == "/Shared/TestFolder" + @pytest.mark.asyncio + async def test_semantic_search_enables_system_index_fallback_when_not_studio_project( + self, + semantic_config, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False) + with patch( + "uipath_langchain.agent.tools.context_tool.ContextGroundingRetriever" + ) as mock_retriever_class: + mock_retriever = AsyncMock() + mock_retriever.ainvoke.return_value = [] + mock_retriever_class.return_value = mock_retriever + + tool = handle_semantic_search("semantic_tool", semantic_config) + assert tool.coroutine is not None + await tool.coroutine(query="test query") + + call_kwargs = mock_retriever_class.call_args.kwargs + assert call_kwargs["include_system_indexes"] is True + + @pytest.mark.asyncio + async def test_semantic_search_disables_system_index_fallback_when_studio_project( + self, + semantic_config, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_PROJECT_ID", "some-project-id") + with patch( + "uipath_langchain.agent.tools.context_tool.ContextGroundingRetriever" + ) as mock_retriever_class: + mock_retriever = AsyncMock() + mock_retriever.ainvoke.return_value = [] + mock_retriever_class.return_value = mock_retriever + + tool = handle_semantic_search("semantic_tool", semantic_config) + assert tool.coroutine is not None + await tool.coroutine(query="test query") + + call_kwargs = mock_retriever_class.call_args.kwargs + assert call_kwargs["include_system_indexes"] is False + class TestHandleBatchTransform: """Test cases for handle_batch_transform function.""" @@ -1095,3 +1137,87 @@ async def test_non_400_enriched_exception_propagates( with pytest.raises(EnrichedException): await tool.coroutine(query="test query") + + +class TestSemanticSearchSystemIndexFallbackIntegration: + """End-to-end mocked test that exercises the full SDK chain. + + Verifies that when not running as a Studio project, the agent's + semantic-search tool resolves the index via the system-indexes + endpoint after the across-folders listing returns empty, and + successfully runs the unified search against the resolved id. + """ + + @pytest.mark.asyncio + async def test_resolves_system_index_and_runs_unified_search( + self, + httpx_mock, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-id") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-id") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "False") + monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False) + monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) + + base = "https://cloud.uipath.com/org/tenant" + httpx_mock.add_response( + url=f"{base}/ecs_/v2/indexes/allacrossfolders?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={"value": []}, + ) + httpx_mock.add_response( + url=f"{base}/ecs_/v2/indexes/allsystemindexes?$expand=dataSource&$filter=Name eq 'system-template-index'", + status_code=200, + json={ + "value": [ + { + "id": "sys-1", + "name": "system-template-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base}/ecs_/v1.2/search/sys-1", + status_code=200, + json={ + "semanticResults": { + "metadata": {"operation_id": "op-1", "strategy": "semantic"}, + "values": [ + { + "id": "doc-1", + "source": "src", + "page_number": 1, + "content": "hello world", + "score": 0.9, + } + ], + } + }, + ) + + resource = _make_context_resource( + name="semantic_tool", + description="Semantic search tool", + index_name="system-template-index", + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + query_variant="dynamic", + ) + + tool = handle_semantic_search("semantic_tool", resource) + assert tool.coroutine is not None + result = await tool.coroutine(query="hi") + + assert "documents" in result + assert len(result["documents"]) == 1 + assert result["documents"][0]["page_content"] == "hello world" + + urls = [str(r.url) for r in httpx_mock.get_requests()] + assert any("/v2/indexes/allacrossfolders" in u for u in urls) + assert any("/v2/indexes/allsystemindexes" in u for u in urls) + assert any("/v1.2/search/sys-1" in u for u in urls) diff --git a/tests/retrievers/test_context_grounding_retriever.py b/tests/retrievers/test_context_grounding_retriever.py new file mode 100644 index 000000000..4c7581174 --- /dev/null +++ b/tests/retrievers/test_context_grounding_retriever.py @@ -0,0 +1,79 @@ +"""Tests for ContextGroundingRetriever's include_system_indexes plumbing.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from uipath.platform import UiPath + +from uipath_langchain.retrievers import ContextGroundingRetriever + + +def _make_unified_search_result() -> MagicMock: + result = MagicMock() + result.semantic_results.values = [] + result.semantic_results.metadata.operation_id = "op-1" + return result + + +def _make_sdk_mock() -> MagicMock: + return MagicMock(spec=UiPath) + + +def test_retriever_forwards_include_system_indexes_when_true() -> None: + sdk = _make_sdk_mock() + sdk.context_grounding.unified_search.return_value = _make_unified_search_result() + + retriever = ContextGroundingRetriever( + index_name="my-index", + uipath_sdk=sdk, + include_system_indexes=True, + ) + retriever.invoke("hello") + + sdk.context_grounding.unified_search.assert_called_once() + kwargs = sdk.context_grounding.unified_search.call_args.kwargs + assert kwargs["include_system_indexes"] is True + + +def test_retriever_defaults_include_system_indexes_to_false() -> None: + sdk = _make_sdk_mock() + sdk.context_grounding.unified_search.return_value = _make_unified_search_result() + + retriever = ContextGroundingRetriever(index_name="my-index", uipath_sdk=sdk) + retriever.invoke("hello") + + kwargs = sdk.context_grounding.unified_search.call_args.kwargs + assert kwargs["include_system_indexes"] is False + + +@pytest.mark.asyncio +async def test_retriever_async_forwards_include_system_indexes_when_true() -> None: + sdk = _make_sdk_mock() + sdk.context_grounding.unified_search_async = AsyncMock( + return_value=_make_unified_search_result() + ) + + retriever = ContextGroundingRetriever( + index_name="my-index", + uipath_sdk=sdk, + include_system_indexes=True, + ) + await retriever.ainvoke("hello") + + sdk.context_grounding.unified_search_async.assert_awaited_once() + kwargs = sdk.context_grounding.unified_search_async.call_args.kwargs + assert kwargs["include_system_indexes"] is True + + +@pytest.mark.asyncio +async def test_retriever_async_defaults_include_system_indexes_to_false() -> None: + sdk = _make_sdk_mock() + sdk.context_grounding.unified_search_async = AsyncMock( + return_value=_make_unified_search_result() + ) + + retriever = ContextGroundingRetriever(index_name="my-index", uipath_sdk=sdk) + await retriever.ainvoke("hello") + + kwargs = sdk.context_grounding.unified_search_async.call_args.kwargs + assert kwargs["include_system_indexes"] is False diff --git a/uv.lock b/uv.lock index 911291179..48fd2e90a 100644 --- a/uv.lock +++ b/uv.lock @@ -4375,7 +4375,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.10.10" +version = "0.10.11" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4459,7 +4459,7 @@ requires-dist = [ { name = "uipath-langchain-client", extras = ["google"], marker = "extra == 'vertex'", specifier = ">=1.10.0,<1.11.0" }, { name = "uipath-langchain-client", extras = ["openai"], specifier = ">=1.10.0,<1.11.0" }, { name = "uipath-langchain-client", extras = ["vertexai"], marker = "extra == 'vertex'", specifier = ">=1.10.0,<1.11.0" }, - { name = "uipath-platform", specifier = ">=0.1.36,<0.2.0" }, + { name = "uipath-platform", specifier = ">=0.1.42,<0.2.0" }, { name = "uipath-runtime", specifier = ">=0.10.0,<0.11.0" }, ] provides-extras = ["anthropic", "vertex", "bedrock", "fireworks", "all"] @@ -4543,7 +4543,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.1.36" +version = "0.1.42" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -4553,9 +4553,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/40/84eef1bb66548cb436472503a0dc6608bd0bc5a44d5bc335489462352082/uipath_platform-0.1.36.tar.gz", hash = "sha256:e6d290450bcc36c4f8dd4ac3766e5b5a02ef0d34f258c8dce8099071cd956217", size = 329335, upload-time = "2026-04-23T22:13:21.299Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/d0/b815b95164294138f679da5a4f703f5f0ba27bbda4cc07c0b6486a325dce/uipath_platform-0.1.42.tar.gz", hash = "sha256:44f3230fb61e63bce73c6151afc6d5f1156a96646eed220b89a8409866e0b315", size = 334236, upload-time = "2026-04-30T16:36:39.946Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/b6/16910fc66506cf18c1b4fa0e0ab51e1a044eeafc00497eb9b4299c0c062d/uipath_platform-0.1.36-py3-none-any.whl", hash = "sha256:c8b552569193deca687c8dada483ce322befba5735739fa5f10c4c096c82241e", size = 216940, upload-time = "2026-04-23T22:13:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a5/db4ecf46424cedddc57fdd2f8edf17b8dab51458a125e53b7f13a25b20db/uipath_platform-0.1.42-py3-none-any.whl", hash = "sha256:9f908c523ab2c9c2dca0542a1afd499e5d049c76deb58a4f4e1df30ba9ad2bb1", size = 219840, upload-time = "2026-04-30T16:36:38.48Z" }, ] [[package]]