From bbfdcf0ba5891bf604721195c14ae146b0d69560 Mon Sep 17 00:00:00 2001 From: Gabriel Martin <91462031+gcuip@users.noreply.github.com> Date: Wed, 27 May 2026 15:46:39 +0200 Subject: [PATCH] fix: use resource_override for index resolution --- pyproject.toml | 2 +- .../agent/tools/context_tool.py | 52 ++++- tests/agent/tools/test_context_tool.py | 197 +++++++++++++++++- uv.lock | 4 +- 4 files changed, 236 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e5fa00c20..2965e7de4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.11.7" +version = "0.11.8" 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" diff --git a/src/uipath_langchain/agent/tools/context_tool.py b/src/uipath_langchain/agent/tools/context_tool.py index 8a6f95c3f..928d46053 100644 --- a/src/uipath_langchain/agent/tools/context_tool.py +++ b/src/uipath_langchain/agent/tools/context_tool.py @@ -21,7 +21,12 @@ ) from uipath.eval.mocks import mockable from uipath.platform import UiPath -from uipath.platform.common import CreateBatchTransform, CreateDeepRag, UiPathConfig +from uipath.platform.common import ( + CreateBatchTransform, + CreateDeepRag, + UiPathConfig, + resource_override, +) from uipath.platform.context_grounding import ( BatchTransformOutputColumn, CitationMode, @@ -134,6 +139,30 @@ def is_static_query(resource: AgentContextResourceConfig) -> bool: return resource.settings.query.variant.lower() == "static" +@resource_override(resource_type="index") +def _apply_index_binding( + name: str | None, folder_path: str | None +) -> tuple[str | None, str | None]: + """Identity passthrough — the @resource_override decorator swaps `name` + and `folder_path` with the BYOC binding overwrite registered for + `index.` (or `index..`) in the active + ResourceOverwritesContext. Returns the arguments unchanged when no + overwrite matches. + """ + return name, folder_path + + +def _resolve_index_binding( + resource: AgentContextResourceConfig, +) -> tuple[str | None, str | None]: + """Resolve the effective index name and folder for a context resource, + honoring any active resource binding overwrite (BYOC).""" + return _apply_index_binding( + name=resource.index_name, + folder_path=resource.folder_path or get_execution_folder_path(), + ) + + def _extract_system_prompt(agent: LowCodeAgentDefinition | None) -> str: """Extract system prompt from agent definition messages.""" if agent is None: @@ -249,9 +278,11 @@ async def context_tool_fn( debug_run = UiPathConfig.is_studio_project + _index_name, _index_folder_path = _resolve_index_binding(resource) + retriever = ContextGroundingRetriever( - index_name=resource.index_name, - folder_path=get_execution_folder_path(), + index_name=_index_name, + folder_path=_index_folder_path, number_of_results=result_count, threshold=threshold, scope_folder=resolved_folder_path_prefix, @@ -331,7 +362,6 @@ def handle_deep_rag( assert resource.settings.query.variant is not None - index_name = resource.index_name if not resource.settings.citation_mode: raise AgentStartupError( code=AgentStartupErrorCode.INVALID_TOOL_CONFIG, @@ -391,14 +421,16 @@ async def context_tool_fn( file_extension=file_extension, ) + _index_name, _index_folder_path = _resolve_index_binding(resource) + @durable_interrupt async def create_deep_rag(): return CreateDeepRag( name=f"task-{uuid.uuid4()}", - index_name=index_name, + index_name=_index_name, prompt=actual_prompt, citation_mode=citation_mode, - index_folder_path=get_execution_folder_path(), + index_folder_path=_index_folder_path, glob_pattern=glob_pattern, ) @@ -442,8 +474,6 @@ def handle_batch_transform( assert resource.settings.query is not None assert resource.settings.query.variant is not None - index_name = resource.index_name - index_folder_path = get_execution_folder_path() if not resource.settings.web_search_grounding: raise AgentStartupError( code=AgentStartupErrorCode.INVALID_TOOL_CONFIG, @@ -522,14 +552,16 @@ async def context_tool_fn( file_extension=None, ) + _index_name, _index_folder_path = _resolve_index_binding(resource) + @durable_interrupt async def create_batch_transform(): return CreateBatchTransform( name=f"task-{uuid.uuid4()}", - index_name=index_name, + index_name=_index_name, prompt=actual_prompt, destination_path=destination_path, - index_folder_path=index_folder_path, + index_folder_path=_index_folder_path, enable_web_search_grounding=enable_web_search_grounding, output_columns=batch_transform_output_columns, storage_bucket_folder_path_prefix=glob_pattern, diff --git a/tests/agent/tools/test_context_tool.py b/tests/agent/tools/test_context_tool.py index 1d2074237..033829b37 100644 --- a/tests/agent/tools/test_context_tool.py +++ b/tests/agent/tools/test_context_tool.py @@ -13,6 +13,10 @@ AgentContextSettings, AgentContextValueSetting, ) +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + _resource_overwrites, +) from uipath.platform.context_grounding import ( CitationMode, DeepRagContent, @@ -321,11 +325,14 @@ async def test_dynamic_query_uses_provided_query(self, base_resource_config): @pytest.mark.asyncio @patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/TestFolder"}) - async def test_deep_rag_uses_execution_folder_path(self, base_resource_config): - """Test that CreateDeepRag receives index_folder_path from the execution environment.""" + async def test_deep_rag_falls_back_to_execution_folder_when_resource_folder_missing( + self, base_resource_config + ): + """Test that CreateDeepRag falls back to the execution folder when the resource has no folder_path.""" resource = base_resource_config( query_variant="static", query_value="test query", + folder_path=None, citation_mode_value=AgentContextValueSetting(value="Inline"), ) tool = handle_deep_rag("test_tool", resource) @@ -340,6 +347,62 @@ async def test_deep_rag_uses_execution_folder_path(self, base_resource_config): deep_rag_arg = mock_interrupt.call_args[0][0] assert deep_rag_arg.index_folder_path == "/Shared/TestFolder" + @pytest.mark.asyncio + async def test_deep_rag_uses_resource_folder_path(self, base_resource_config): + """Test that CreateDeepRag prefers the resource's configured folder_path.""" + resource = base_resource_config( + query_variant="static", + query_value="test query", + folder_path="/Configured/Folder", + citation_mode_value=AgentContextValueSetting(value="Inline"), + ) + tool = handle_deep_rag("test_tool", resource) + + with patch( + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" + ) as mock_interrupt: + mock_interrupt.return_value = {"mocked": "response"} + assert tool.coroutine is not None + await tool.coroutine() + + deep_rag_arg = mock_interrupt.call_args[0][0] + assert deep_rag_arg.index_folder_path == "/Configured/Folder" + + @pytest.mark.asyncio + async def test_deep_rag_applies_binding_overwrite(self, base_resource_config): + """Test that CreateDeepRag uses the BYOC binding overwrite's name and folder.""" + resource = base_resource_config( + query_variant="static", + query_value="test query", + index_name="original-index", + folder_path="/Configured/Folder", + citation_mode_value=AgentContextValueSetting(value="Inline"), + ) + tool = handle_deep_rag("test_tool", resource) + + token = _resource_overwrites.set( + { + "index.original-index": GenericResourceOverwrite( + resource_type="index", + name="overridden-index", + folder_path="/Overridden/Folder", + ) + } + ) + try: + with patch( + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" + ) as mock_interrupt: + mock_interrupt.return_value = {"mocked": "response"} + assert tool.coroutine is not None + await tool.coroutine() + + deep_rag_arg = mock_interrupt.call_args[0][0] + assert deep_rag_arg.index_name == "overridden-index" + assert deep_rag_arg.index_folder_path == "/Overridden/Folder" + finally: + _resource_overwrites.reset(token) + class TestCreateContextTool: """Test cases for create_context_tool function.""" @@ -522,8 +585,11 @@ async def test_static_query_uses_predefined_query(self): @pytest.mark.asyncio @patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/TestFolder"}) - async def test_semantic_search_uses_execution_folder_path(self, semantic_config): - """Test that ContextGroundingRetriever receives folder_path from the execution environment.""" + async def test_semantic_search_falls_back_to_execution_folder_when_resource_folder_missing( + self, semantic_config + ): + """Test that the retriever receives the execution folder when the resource has no folder_path.""" + semantic_config.folder_path = None with patch( "uipath_langchain.agent.tools.context_tool.ContextGroundingRetriever" ) as mock_retriever_class: @@ -538,6 +604,57 @@ 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_uses_resource_folder_path(self, semantic_config): + """Test that the retriever prefers the resource's configured folder_path.""" + semantic_config.folder_path = "/Configured/Folder" + 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[1] + assert call_kwargs["folder_path"] == "/Configured/Folder" + assert call_kwargs["index_name"] == semantic_config.index_name + + @pytest.mark.asyncio + async def test_semantic_search_applies_binding_overwrite(self, semantic_config): + """Test that the retriever uses the BYOC binding overwrite's name and folder.""" + semantic_config.index_name = "original-index" + semantic_config.folder_path = "/Configured/Folder" + token = _resource_overwrites.set( + { + "index.original-index": GenericResourceOverwrite( + resource_type="index", + name="overridden-index", + folder_path="/Overridden/Folder", + ) + } + ) + try: + 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[1] + assert call_kwargs["folder_path"] == "/Overridden/Folder" + assert call_kwargs["index_name"] == "overridden-index" + finally: + _resource_overwrites.reset(token) + @pytest.mark.asyncio async def test_semantic_search_enables_system_index_fallback_in_studio_project( self, @@ -900,10 +1017,11 @@ async def test_dynamic_query_batch_transform_uses_default_destination_path(self) @pytest.mark.asyncio @patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/TestFolder"}) - async def test_batch_transform_uses_execution_folder_path( + async def test_batch_transform_falls_back_to_execution_folder_when_resource_folder_missing( self, batch_transform_config ): - """Test that CreateBatchTransform receives index_folder_path from the execution environment.""" + """Test that CreateBatchTransform falls back to the execution folder when the resource has no folder_path.""" + batch_transform_config.folder_path = None tool = handle_batch_transform("batch_transform_tool", batch_transform_config) mock_uipath = MagicMock() @@ -924,6 +1042,72 @@ async def test_batch_transform_uses_execution_folder_path( batch_transform_arg = mock_interrupt.call_args[0][0] assert batch_transform_arg.index_folder_path == "/Shared/TestFolder" + @pytest.mark.asyncio + async def test_batch_transform_uses_resource_folder_path( + self, batch_transform_config + ): + """Test that CreateBatchTransform prefers the resource's configured folder_path.""" + batch_transform_config.folder_path = "/Configured/Folder" + tool = handle_batch_transform("batch_transform_tool", batch_transform_config) + + mock_uipath = MagicMock() + mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id") + with ( + patch( + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" + ) as mock_interrupt, + patch( + "uipath_langchain.agent.tools.context_tool.UiPath", + return_value=mock_uipath, + ), + ): + mock_interrupt.return_value = MagicMock() + assert tool.coroutine is not None + await tool.coroutine(destination_path="output.csv") + + batch_transform_arg = mock_interrupt.call_args[0][0] + assert batch_transform_arg.index_folder_path == "/Configured/Folder" + + @pytest.mark.asyncio + async def test_batch_transform_applies_binding_overwrite( + self, batch_transform_config + ): + """Test that CreateBatchTransform uses the BYOC binding overwrite's name and folder.""" + batch_transform_config.index_name = "original-index" + batch_transform_config.folder_path = "/Configured/Folder" + tool = handle_batch_transform("batch_transform_tool", batch_transform_config) + + token = _resource_overwrites.set( + { + "index.original-index": GenericResourceOverwrite( + resource_type="index", + name="overridden-index", + folder_path="/Overridden/Folder", + ) + } + ) + mock_uipath = MagicMock() + mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id") + try: + with ( + patch( + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" + ) as mock_interrupt, + patch( + "uipath_langchain.agent.tools.context_tool.UiPath", + return_value=mock_uipath, + ), + ): + mock_interrupt.return_value = MagicMock() + assert tool.coroutine is not None + await tool.coroutine(destination_path="output.csv") + + batch_transform_arg = mock_interrupt.call_args[0][0] + assert batch_transform_arg.index_name == "overridden-index" + assert batch_transform_arg.index_folder_path == "/Overridden/Folder" + finally: + _resource_overwrites.reset(token) + class TestBuildGlobPattern: """Test cases for build_glob_pattern function.""" @@ -1218,6 +1402,7 @@ async def test_resolves_system_index_and_runs_unified_search( name="semantic_tool", description="Semantic search tool", index_name="system-template-index", + folder_path=None, retrieval_mode=AgentContextRetrievalMode.SEMANTIC, query_variant="dynamic", ) diff --git a/uv.lock b/uv.lock index 66ae9f583..217027b97 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-05-25T13:34:29.587981Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -4388,7 +4388,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.11.7" +version = "0.11.8" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },