Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
52 changes: 42 additions & 10 deletions src/uipath_langchain/agent/tools/context_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.<name>` (or `index.<name>.<folder_path>`) 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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
197 changes: 191 additions & 6 deletions tests/agent/tools/test_context_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
AgentContextSettings,
AgentContextValueSetting,
)
from uipath.platform.common._bindings import (
GenericResourceOverwrite,
_resource_overwrites,
)
from uipath.platform.context_grounding import (
CitationMode,
DeepRagContent,
Expand Down Expand Up @@ -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)
Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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."""
Expand Down Expand Up @@ -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",
)
Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading