diff --git a/pyproject.toml b/pyproject.toml index 1342465e..47e483e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath-langchain" -version = "0.8.20" +version = "0.8.21" 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.0, <2.11.0", "uipath-core>=0.5.2, <0.6.0", - "uipath-platform>=0.0.18, <0.1.0", + "uipath-platform>=0.0.23, <0.1.0", "uipath-runtime>=0.9.1, <0.10.0", "langgraph>=1.0.0, <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 84f61d45..6eced4d9 100644 --- a/src/uipath_langchain/agent/tools/context_tool.py +++ b/src/uipath_langchain/agent/tools/context_tool.py @@ -1,15 +1,18 @@ """Context tool creation for semantic index retrieval.""" +import logging import uuid -from typing import Any, Dict, Optional +from typing import Any, Optional, cast +from jsonpath_ng import parse # type: ignore[import-untyped] from langchain_core.documents import Document from langchain_core.messages import ToolCall from langchain_core.tools import BaseTool, StructuredTool -from pydantic import BaseModel, Field, TypeAdapter, create_model +from pydantic import BaseModel, Field, create_model from uipath.agent.models.agent import ( AgentContextResourceConfig, AgentContextRetrievalMode, + AgentToolArgumentArgumentProperties, AgentToolArgumentProperties, ) from uipath.eval.mocks import mockable @@ -31,7 +34,10 @@ from uipath_langchain.agent.tools.internal_tools.schema_utils import ( BATCH_TRANSFORM_OUTPUT_SCHEMA, ) -from uipath_langchain.agent.tools.static_args import handle_static_args +from uipath_langchain.agent.tools.static_args import ( + ArgumentPropertiesMixin, + handle_static_args, +) from uipath_langchain.retrievers import ContextGroundingRetriever from .durable_interrupt import durable_interrupt @@ -42,44 +48,67 @@ from .tool_node import ToolWrapperReturnType from .utils import sanitize_tool_name -_ARG_PROPS_ADAPTER = TypeAdapter(Dict[str, AgentToolArgumentProperties]) +logger = logging.getLogger(__name__) -def _get_argument_properties( +def _build_arg_props_from_settings( resource: AgentContextResourceConfig, ) -> dict[str, AgentToolArgumentProperties]: - """Extract argumentProperties from the resource's extra fields. + """Build argument_properties from context resource settings. - AgentContextResourceConfig doesn't declare argument_properties yet, - but BaseCfg(extra="allow") preserves the raw JSON value. + Context resources don't receive argumentProperties from the frontend. + Instead, we derive them from the settings when variant="argument". + Only includes fields that belong in the tool's args_schema (i.e. query). """ - raw = ( - resource.model_extra.get("argumentProperties") if resource.model_extra else None - ) - if not raw: - return {} - return _ARG_PROPS_ADAPTER.validate_python(raw) + assert resource.settings is not None + arg_props: dict[str, AgentToolArgumentProperties] = {} + + if resource.settings.query and resource.settings.query.variant == "argument": + argument_path = (resource.settings.query.value or "").strip("{}") + arg_props["query"] = AgentToolArgumentArgumentProperties( + is_sensitive=False, + argument_path=argument_path, + ) + + return arg_props -def _build_folder_path_prefix_arg_props( +def _resolve_folder_path_prefix_from_state( resource: AgentContextResourceConfig, -) -> dict[str, Any]: - """Build argument_properties for folder_path_prefix from settings. + state: dict[str, Any], +) -> str | None: + """Resolve folder_path_prefix from agent state using jsonpath from settings.""" + assert resource.settings is not None + setting = resource.settings.folder_path_prefix + if not setting or setting.variant != "argument" or not setting.value: + return None + argument_path = "$." + setting.value.strip("{}") + matches = parse(argument_path).find(state) + return matches[0].value if matches else None - Fallback for when settings bag doesn't include argumentProperties - at the resource level but does set settings.folder_path_prefix - with variant="argument". - """ + +def _resolve_file_extension(resource: AgentContextResourceConfig) -> str | None: + """Resolve file extension from settings, returning None for 'All' or empty.""" + if resource.settings.file_extension and resource.settings.file_extension.value: + ext = resource.settings.file_extension.value + if ext.lower() == "all": + return None + return ext + return None + + +def _resolve_static_folder_path_prefix( + resource: AgentContextResourceConfig, +) -> str | None: + """Resolve static folder_path_prefix from settings.""" assert resource.settings is not None - assert resource.settings.folder_path_prefix is not None - argument_path = (resource.settings.folder_path_prefix.value or "").strip("{}") - return { - "folder_path_prefix": { - "variant": "argument", - "argumentPath": argument_path, - "isSensitive": False, - } - } + if ( + resource.settings.folder_path_prefix + and resource.settings.folder_path_prefix.value + and resource.settings.folder_path_prefix.variant == "static" + ): + return resource.settings.folder_path_prefix.value + return None def is_static_query(resource: AgentContextResourceConfig) -> bool: @@ -91,7 +120,6 @@ def is_static_query(resource: AgentContextResourceConfig) -> bool: def create_context_tool(resource: AgentContextResourceConfig) -> StructuredTool: - assert resource.settings is not None tool_name = sanitize_tool_name(resource.name) retrieval_mode = resource.settings.retrieval_mode.lower() if retrieval_mode == AgentContextRetrievalMode.DEEP_RAG.value.lower(): @@ -105,22 +133,20 @@ def create_context_tool(resource: AgentContextResourceConfig) -> StructuredTool: def handle_semantic_search( tool_name: str, resource: AgentContextResourceConfig ) -> StructuredTool: - assert resource.settings is not None ensure_valid_fields(resource) assert resource.settings.query.variant is not None - retriever = ContextGroundingRetriever( - index_name=resource.index_name, - folder_path=get_execution_folder_path(), - number_of_results=resource.settings.result_count, - ) + file_extension = _resolve_file_extension(resource) + static_folder_path_prefix = _resolve_static_folder_path_prefix(resource) static = is_static_query(resource) prompt = resource.settings.query.value if static else None if static: assert prompt is not None + arg_props = _build_arg_props_from_settings(resource) + class ContextOutputSchemaModel(BaseModel): documents: list[Document] = Field( ..., description="List of retrieved documents." @@ -128,16 +154,33 @@ class ContextOutputSchemaModel(BaseModel): output_model = ContextOutputSchemaModel - schema_fields: dict[str, Any] = ( - {} - if static - else { - "query": ( - str, - Field(..., description="The query to search for in the knowledge base"), + schema_fields: dict[str, Any] = {} + + if "query" in arg_props: + schema_fields["query"] = ( + str, + Field( + default=None, + description="The query to search for in the knowledge base", ), - } + ) + elif not static: + schema_fields["query"] = ( + str, + Field( + ..., + description="The query to search for in the knowledge base", + ), + ) + + has_arg_folder = ( + resource.settings.folder_path_prefix + and resource.settings.folder_path_prefix.variant == "argument" + and resource.settings.folder_path_prefix.value ) + + _resolved_arg_folder_prefix: str | None = None + input_model = create_model("SemanticSearchInput", **schema_fields) @mockable( @@ -147,7 +190,22 @@ class ContextOutputSchemaModel(BaseModel): output_schema=output_model.model_json_schema(), example_calls=[], # Examples cannot be provided for context. ) - async def context_tool_fn(query: Optional[str] = None) -> dict[str, Any]: + async def context_tool_fn( + query: Optional[str] = None, + ) -> dict[str, Any]: + resolved_folder_path_prefix = ( + static_folder_path_prefix or _resolved_arg_folder_prefix + ) + + retriever = ContextGroundingRetriever( + index_name=resource.index_name, + folder_path=get_execution_folder_path(), + number_of_results=resource.settings.result_count, + threshold=resource.settings.threshold, + scope_folder=resolved_folder_path_prefix, + scope_extension=file_extension, + ) + actual_query = prompt or query assert actual_query is not None docs = await retriever.ainvoke(actual_query) @@ -158,6 +216,39 @@ async def context_tool_fn(query: Optional[str] = None) -> dict[str, Any]: ] } + if arg_props or has_arg_folder: + + async def context_semantic_search_wrapper( + tool: BaseTool, + call: ToolCall, + state: AgentGraphState, + ) -> ToolWrapperReturnType: + call["args"] = handle_static_args( + cast(ArgumentPropertiesMixin, tool), state, call["args"] + ) + nonlocal _resolved_arg_folder_prefix + _resolved_arg_folder_prefix = _resolve_folder_path_prefix_from_state( + resource, dict(state) + ) + return await tool.ainvoke(call) + + tool = StructuredToolWithArgumentProperties( + name=tool_name, + description=resource.description, + args_schema=input_model, + coroutine=context_tool_fn, + output_type=output_model, + argument_properties=arg_props, + metadata={ + "tool_type": "context", + "display_name": resource.name, + "index_name": resource.index_name, + "context_retrieval_mode": resource.settings.retrieval_mode, + }, + ) + tool.set_tool_wrappers(awrapper=context_semantic_search_wrapper) + return tool + return StructuredToolWithOutputType( name=tool_name, description=resource.description, @@ -175,8 +266,7 @@ async def context_tool_fn(query: Optional[str] = None) -> dict[str, Any]: def handle_deep_rag( tool_name: str, resource: AgentContextResourceConfig -) -> StructuredTool: - assert resource.settings is not None +) -> StructuredToolWithArgumentProperties: ensure_valid_fields(resource) assert resource.settings.query.variant is not None @@ -196,17 +286,8 @@ def handle_deep_rag( if static: assert prompt is not None - static_folder_path_prefix = None - if ( - resource.settings.folder_path_prefix - and resource.settings.folder_path_prefix.value - and resource.settings.folder_path_prefix.variant == "static" - ): - static_folder_path_prefix = resource.settings.folder_path_prefix.value - - file_extension = None - if resource.settings.file_extension and resource.settings.file_extension.value: - file_extension = resource.settings.file_extension.value + static_folder_path_prefix = _resolve_static_folder_path_prefix(resource) + file_extension = _resolve_file_extension(resource) output_model = create_model( "DeepRagOutputModel", @@ -214,12 +295,7 @@ def handle_deep_rag( deep_rag_id=(str, Field(alias="deepRagId")), ) - arg_props = _get_argument_properties(resource) - - has_folder_path_prefix_arg = "folder_path_prefix" in arg_props or ( - resource.settings.folder_path_prefix - and resource.settings.folder_path_prefix.variant == "argument" - ) + arg_props = _build_arg_props_from_settings(resource) schema_fields: dict[str, Any] = ( {} @@ -235,19 +311,10 @@ def handle_deep_rag( } ) - if has_folder_path_prefix_arg: - schema_fields["folder_path_prefix"] = ( - str, - Field( - default=None, - description="The folder path prefix within the index to filter on", - ), - ) - if "folder_path_prefix" not in arg_props: - arg_props = _build_folder_path_prefix_arg_props(resource) - input_model = create_model("DeepRagInput", **schema_fields) + _resolved_arg_folder_prefix: str | None = None + @mockable( name=resource.name, description=resource.description, @@ -256,11 +323,11 @@ def handle_deep_rag( example_calls=[], # Examples cannot be provided for context. ) async def context_tool_fn( - query: Optional[str] = None, folder_path_prefix: Optional[str] = None + query: Optional[str] = None, ) -> dict[str, Any]: actual_prompt = prompt or query glob_pattern = build_glob_pattern( - folder_path_prefix=static_folder_path_prefix or folder_path_prefix, + folder_path_prefix=static_folder_path_prefix or _resolved_arg_folder_prefix, file_extension=file_extension, ) @@ -277,7 +344,21 @@ async def create_deep_rag(): return await create_deep_rag() - return StructuredToolWithArgumentProperties( + async def context_deep_rag_wrapper( + tool: BaseTool, + call: ToolCall, + state: AgentGraphState, + ) -> ToolWrapperReturnType: + nonlocal _resolved_arg_folder_prefix + call["args"] = handle_static_args( + cast(ArgumentPropertiesMixin, tool), state, call["args"] + ) + _resolved_arg_folder_prefix = _resolve_folder_path_prefix_from_state( + resource, dict(state) + ) + return await tool.ainvoke(call) + + tool = StructuredToolWithArgumentProperties( name=tool_name, description=resource.description, args_schema=input_model, @@ -291,12 +372,13 @@ async def create_deep_rag(): "context_retrieval_mode": resource.settings.retrieval_mode, }, ) + tool.set_tool_wrappers(awrapper=context_deep_rag_wrapper) + return tool def handle_batch_transform( tool_name: str, resource: AgentContextResourceConfig -) -> StructuredTool: - assert resource.settings is not None +) -> StructuredToolWithArgumentProperties: ensure_valid_fields(resource) assert resource.settings.query is not None @@ -339,20 +421,9 @@ def handle_batch_transform( if static: assert prompt is not None - static_folder_path_prefix = None - if ( - resource.settings.folder_path_prefix - and resource.settings.folder_path_prefix.value - and resource.settings.folder_path_prefix.variant == "static" - ): - static_folder_path_prefix = resource.settings.folder_path_prefix.value + static_folder_path_prefix = _resolve_static_folder_path_prefix(resource) - arg_props = _get_argument_properties(resource) - - has_folder_path_prefix_arg = "folder_path_prefix" in arg_props or ( - resource.settings.folder_path_prefix - and resource.settings.folder_path_prefix.variant == "argument" - ) + arg_props = _build_arg_props_from_settings(resource) output_model = create_model_from_schema(BATCH_TRANSFORM_OUTPUT_SCHEMA) @@ -372,18 +443,10 @@ def handle_batch_transform( description="The relative file path destination for the modified csv file", ), ) - if has_folder_path_prefix_arg: - schema_fields["folder_path_prefix"] = ( - str, - Field( - default=None, - description="The folder path prefix within the index to filter on", - ), - ) - if "folder_path_prefix" not in arg_props: - arg_props = _build_folder_path_prefix_arg_props(resource) input_model = create_model("BatchTransformInput", **schema_fields) + _resolved_arg_folder_prefix: str | None = None + @mockable( name=resource.name, description=resource.description, @@ -394,11 +457,10 @@ def handle_batch_transform( async def context_tool_fn( query: Optional[str] = None, destination_path: str = "output.csv", - folder_path_prefix: Optional[str] = None, ) -> dict[str, Any]: actual_prompt = prompt or query glob_pattern = build_glob_pattern( - folder_path_prefix=static_folder_path_prefix or folder_path_prefix, + folder_path_prefix=static_folder_path_prefix or _resolved_arg_folder_prefix, file_extension=None, ) @@ -441,7 +503,13 @@ async def context_batch_transform_wrapper( call: ToolCall, state: AgentGraphState, ) -> ToolWrapperReturnType: - call["args"] = handle_static_args(resource, state, call["args"]) + call["args"] = handle_static_args( + cast(ArgumentPropertiesMixin, tool), state, call["args"] + ) + nonlocal _resolved_arg_folder_prefix + _resolved_arg_folder_prefix = _resolve_folder_path_prefix_from_state( + resource, dict(state) + ) return await job_attachment_wrapper(tool, call, state) tool = StructuredToolWithArgumentProperties( @@ -464,7 +532,6 @@ async def context_batch_transform_wrapper( def ensure_valid_fields(resource_config: AgentContextResourceConfig): - assert resource_config.settings is not None if not resource_config.settings.query.variant: raise AgentStartupError( code=AgentStartupErrorCode.INVALID_TOOL_CONFIG, @@ -482,29 +549,35 @@ def ensure_valid_fields(resource_config: AgentContextResourceConfig): ) +def _normalize_folder_prefix(folder_path_prefix: str | None) -> str: + """Normalize a folder path prefix to a clean directory-only pattern. + + Strips leading/trailing slashes and trailing file-matching globs + (e.g. /*, /**, /**/*) since the caller appends the file extension part. + """ + if not folder_path_prefix: + return "**" + + prefix = folder_path_prefix.strip("/").rstrip("/*") + if not prefix: + return "**" + + return prefix + + def build_glob_pattern( folder_path_prefix: str | None, file_extension: str | None ) -> str: - # Handle prefix - prefix = "**" - if folder_path_prefix: - prefix = folder_path_prefix.rstrip("/") - - if not prefix.startswith("**"): - if prefix.startswith("/"): - prefix = prefix[1:] + prefix = _normalize_folder_prefix(folder_path_prefix) # Handle extension extension = "*" if file_extension: ext = file_extension.lower() - if ext in {"pdf", "txt", "docx", "csv"}: - extension = f"*.{ext}" - else: - extension = f"*.{ext}" + extension = f"*.{ext}" # Final pattern logic - if not prefix or prefix == "**": + if prefix == "**": return "**/*" if extension == "*" else f"**/{extension}" return f"{prefix}/{extension}" diff --git a/src/uipath_langchain/retrievers/context_grounding_retriever.py b/src/uipath_langchain/retrievers/context_grounding_retriever.py index 0a7f02c9..dc448c6a 100644 --- a/src/uipath_langchain/retrievers/context_grounding_retriever.py +++ b/src/uipath_langchain/retrievers/context_grounding_retriever.py @@ -5,6 +5,7 @@ from langchain_core.documents import Document from langchain_core.retrievers import BaseRetriever from uipath.platform import UiPath +from uipath.platform.context_grounding import UnifiedSearchScope class ContextGroundingRetriever(BaseRetriever): @@ -13,48 +14,74 @@ class ContextGroundingRetriever(BaseRetriever): folder_key: str | None = None uipath_sdk: UiPath | None = None number_of_results: int | None = 10 + threshold: float = 0.0 + scope_folder: str | None = None + scope_extension: str | None = None + + def _build_scope(self) -> UnifiedSearchScope | None: + if self.scope_folder or self.scope_extension: + return UnifiedSearchScope( + folder=self.scope_folder, + extension=self.scope_extension, + ) + return None def _get_relevant_documents( self, query: str, *, run_manager: CallbackManagerForRetrieverRun ) -> list[Document]: - """Sync implementations for retriever calls context_grounding API to search the requested index.""" + """Sync implementation calls context_grounding unified_search API.""" sdk = self.uipath_sdk if self.uipath_sdk is not None else UiPath() - results = sdk.context_grounding.search( + result = sdk.context_grounding.unified_search( self.index_name, query, - self.number_of_results if self.number_of_results is not None else 10, + number_of_results=self.number_of_results + if self.number_of_results is not None + else 10, + threshold=self.threshold, + scope=self._build_scope(), folder_path=self.folder_path, folder_key=self.folder_key, ) + values = result.semantic_results.values if result.semantic_results else [] + return [ Document( page_content=x.content, metadata={ "source": x.source, + "search_id": result.metadata.operation_id + if result.metadata + else None, "reference": x.reference, "page_number": x.page_number, "score": x.score, }, ) - for x in results + for x in values ] async def _aget_relevant_documents( self, query: str, *, run_manager: AsyncCallbackManagerForRetrieverRun ) -> list[Document]: - """Async implementations for retriever calls context_grounding API to search the requested index.""" + """Async implementation calls context_grounding unified_search_async API.""" sdk = self.uipath_sdk if self.uipath_sdk is not None else UiPath() - results = await sdk.context_grounding.search_async( + result = await sdk.context_grounding.unified_search_async( self.index_name, query, - self.number_of_results if self.number_of_results is not None else 10, + number_of_results=self.number_of_results + if self.number_of_results is not None + else 10, + threshold=self.threshold, + scope=self._build_scope(), folder_path=self.folder_path, folder_key=self.folder_key, ) + values = result.semantic_results.values if result.semantic_results else [] + return [ Document( page_content=x.content, @@ -65,5 +92,5 @@ async def _aget_relevant_documents( "score": x.score, }, ) - for x in results + for x in values ] diff --git a/tests/agent/tools/test_context_tool.py b/tests/agent/tools/test_context_tool.py index 47f207f5..eaaabf00 100644 --- a/tests/agent/tools/test_context_tool.py +++ b/tests/agent/tools/test_context_tool.py @@ -20,54 +20,63 @@ from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode from uipath_langchain.agent.tools.context_tool import ( + _normalize_folder_prefix, build_glob_pattern, create_context_tool, handle_batch_transform, handle_deep_rag, handle_semantic_search, ) +from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( + StructuredToolWithArgumentProperties, +) from uipath_langchain.agent.tools.structured_tool_with_output_type import ( StructuredToolWithOutputType, ) +def _make_context_resource( + name="test_tool", + description="Test tool", + index_name="test-index", + folder_path="/test/folder", + query_value=None, + query_variant="static", + citation_mode_value=None, + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + folder_path_prefix=None, + **kwargs, +): + """Helper to create an AgentContextResourceConfig.""" + return AgentContextResourceConfig( + name=name, + description=description, + resource_type="context", + index_name=index_name, + folder_path=folder_path, + settings=AgentContextSettings( + result_count=1, + retrieval_mode=retrieval_mode, + query=AgentContextQuerySetting( + value=query_value, + description="some description", + variant=query_variant, + ), + citation_mode=citation_mode_value, + folder_path_prefix=folder_path_prefix, + ), + is_enabled=True, + **kwargs, + ) + + class TestHandleDeepRag: """Test cases for handle_deep_rag function.""" @pytest.fixture def base_resource_config(self): """Fixture for base resource configuration.""" - - def _create_config( - name="test_deep_rag", - description="Test Deep RAG tool", - index_name="test-index", - folder_path="/test/folder", - query_value=None, - query_variant="static", - citation_mode_value=None, - retrieval_mode=AgentContextRetrievalMode.SEMANTIC, - ): - return AgentContextResourceConfig( - name=name, - description=description, - resource_type="context", - index_name=index_name, - folder_path=folder_path, - settings=AgentContextSettings( - result_count=1, - retrieval_mode=retrieval_mode, - query=AgentContextQuerySetting( - value=query_value, - description="some description", - variant=query_variant, - ), - citation_mode=citation_mode_value, - ), - is_enabled=True, - ) - - return _create_config + return _make_context_resource def test_successful_deep_rag_creation(self, base_resource_config): """Test successful creation of Deep RAG tool with all required fields.""" @@ -78,9 +87,9 @@ def test_successful_deep_rag_creation(self, base_resource_config): result = handle_deep_rag("test_deep_rag", resource) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) assert result.name == "test_deep_rag" - assert result.description == "Test Deep RAG tool" + assert result.description == "Test tool" assert hasattr(result.args_schema, "model_json_schema") assert result.args_schema.model_json_schema()["properties"] == {} assert issubclass(result.output_type, DeepRagContent) @@ -88,6 +97,35 @@ def test_successful_deep_rag_creation(self, base_resource_config): assert "deepRagId" in schema["properties"] assert schema["properties"]["deepRagId"]["type"] == "string" + def test_deep_rag_has_tool_wrapper(self, base_resource_config): + """Test that Deep RAG tool has a tool wrapper for static args resolution.""" + resource = base_resource_config( + citation_mode_value=AgentContextValueSetting(value="Inline"), + query_value="some query", + ) + + result = handle_deep_rag("test_deep_rag", resource) + + assert result.awrapper is not None + + def test_deep_rag_with_folder_path_prefix_from_settings(self, base_resource_config): + """Test that folder_path_prefix with argument variant is resolved in wrapper, not via argument_properties.""" + resource = base_resource_config( + citation_mode_value=AgentContextValueSetting(value="Inline"), + query_value="some query", + folder_path_prefix=AgentContextQuerySetting( + value="{deepRagFolderPrefix}", variant="argument" + ), + ) + + result = handle_deep_rag("test_deep_rag", resource) + + assert isinstance(result, StructuredToolWithArgumentProperties) + # folder_path_prefix is resolved directly in the wrapper from state, + # not via argument_properties or args_schema + assert "folder_path_prefix" not in result.argument_properties + assert isinstance(result.args_schema, type) + def test_missing_static_query_value_raises_error(self, base_resource_config): """Test that missing query.value for static variant raises AgentStartupError.""" resource = base_resource_config(query_variant="static", query_value=None) @@ -139,7 +177,7 @@ def test_citation_mode_conversion( result = handle_deep_rag("test_deep_rag", resource) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) def test_tool_name_preserved(self, base_resource_config): """Test that the sanitized tool name is correctly applied.""" @@ -228,9 +266,9 @@ def test_dynamic_query_deep_rag_creation(self, base_resource_config): result = handle_deep_rag("test_deep_rag", resource) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) assert result.name == "test_deep_rag" - assert result.description == "Test Deep RAG tool" + assert result.description == "Test tool" assert result.args_schema is not None # Dynamic has input schema assert issubclass(result.output_type, DeepRagContent) @@ -300,44 +338,23 @@ class TestCreateContextTool: @pytest.fixture def semantic_search_config(self): """Fixture for semantic search configuration.""" - return AgentContextResourceConfig( + return _make_context_resource( name="test_semantic_search", description="Test semantic search", - resource_type="context", - index_name="test-index", - folder_path="/test/folder", - settings=AgentContextSettings( - result_count=10, - retrieval_mode=AgentContextRetrievalMode.SEMANTIC, - query=AgentContextQuerySetting( - value=None, - description="Query for semantic search", - variant="dynamic", - ), - ), - is_enabled=True, + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + query_variant="dynamic", ) @pytest.fixture def deep_rag_config(self): """Fixture for deep RAG configuration.""" - return AgentContextResourceConfig( + return _make_context_resource( name="test_deep_rag", description="Test Deep RAG", - resource_type="context", - index_name="test-index", - folder_path="/test/folder", - settings=AgentContextSettings( - result_count=5, - retrieval_mode=AgentContextRetrievalMode.DEEP_RAG, - query=AgentContextQuerySetting( - value="test query", - description="Test query description", - variant="static", - ), - citation_mode=AgentContextValueSetting(value="Inline"), - ), - is_enabled=True, + retrieval_mode=AgentContextRetrievalMode.DEEP_RAG, + query_value="test query", + query_variant="static", + citation_mode_value=AgentContextValueSetting(value="Inline"), ) def test_create_semantic_search_tool(self, semantic_search_config): @@ -352,7 +369,7 @@ def test_create_deep_rag_tool(self, deep_rag_config): """Test that deep_rag retrieval mode creates Deep RAG tool.""" result = create_context_tool(deep_rag_config) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) assert result.name == "test_deep_rag" assert hasattr(result.args_schema, "model_json_schema") assert result.args_schema.model_json_schema()["properties"] == {} @@ -361,14 +378,14 @@ def test_create_deep_rag_tool(self, deep_rag_config): def test_case_insensitive_retrieval_mode(self, deep_rag_config): """Test that retrieval mode matching is case-insensitive.""" # Test with uppercase - deep_rag_config.settings.retrieval_mode = "DEEP_RAG" + deep_rag_config.settings.retrieval_mode = "DEEPRAG" result = create_context_tool(deep_rag_config) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) # Test with mixed case - deep_rag_config.settings.retrieval_mode = "Deep_Rag" + deep_rag_config.settings.retrieval_mode = "deeprag" result = create_context_tool(deep_rag_config) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) class TestHandleSemanticSearch: @@ -377,22 +394,11 @@ class TestHandleSemanticSearch: @pytest.fixture def semantic_config(self): """Fixture for semantic search configuration.""" - return AgentContextResourceConfig( + return _make_context_resource( name="semantic_tool", description="Semantic search tool", - resource_type="context", - index_name="test-index", - folder_path="/test/folder", - settings=AgentContextSettings( - result_count=5, - retrieval_mode=AgentContextRetrievalMode.SEMANTIC, - query=AgentContextQuerySetting( - value=None, - description="Query for semantic search", - variant="dynamic", - ), - ), - is_enabled=True, + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + query_variant="dynamic", ) def test_semantic_search_tool_creation(self, semantic_config): @@ -445,22 +451,12 @@ async def test_semantic_search_returns_documents(self, semantic_config): def test_static_query_semantic_search_creation(self): """Test successful creation of semantic search tool with static query.""" - resource = AgentContextResourceConfig( + resource = _make_context_resource( name="semantic_tool", description="Semantic search tool", - resource_type="context", - index_name="test-index", - folder_path="/test/folder", - settings=AgentContextSettings( - result_count=5, - retrieval_mode=AgentContextRetrievalMode.SEMANTIC, - query=AgentContextQuerySetting( - value="predefined static query", - description="Static query for semantic search", - variant="static", - ), - ), - is_enabled=True, + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + query_value="predefined static query", + query_variant="static", ) result = handle_semantic_search("semantic_tool", resource) @@ -474,22 +470,12 @@ def test_static_query_semantic_search_creation(self): @pytest.mark.asyncio async def test_static_query_uses_predefined_query(self): """Test that static query variant uses the predefined query value.""" - resource = AgentContextResourceConfig( + resource = _make_context_resource( name="semantic_tool", description="Semantic search tool", - resource_type="context", - index_name="test-index", - folder_path="/test/folder", - settings=AgentContextSettings( - result_count=5, - retrieval_mode=AgentContextRetrievalMode.SEMANTIC, - query=AgentContextQuerySetting( - value="predefined static query", - description="Static query for semantic search", - variant="static", - ), - ), - is_enabled=True, + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + query_value="predefined static query", + query_variant="static", ) mock_documents = [ @@ -512,13 +498,20 @@ async def test_static_query_uses_predefined_query(self): assert "documents" in result assert len(result["documents"]) == 1 + @pytest.mark.asyncio @patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/TestFolder"}) - def test_semantic_search_uses_execution_folder_path(self, semantic_config): + async def test_semantic_search_uses_execution_folder_path(self, semantic_config): """Test that ContextGroundingRetriever receives folder_path from the execution environment.""" with patch( "uipath_langchain.agent.tools.context_tool.ContextGroundingRetriever" ) as mock_retriever_class: - handle_semantic_search("semantic_tool", semantic_config) + 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"] == "/Shared/TestFolder" @@ -561,7 +554,7 @@ def test_static_query_batch_transform_creation(self, batch_transform_config): """Test successful creation of batch transform tool with static query.""" result = handle_batch_transform("batch_transform_tool", batch_transform_config) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) assert result.name == "batch_transform_tool" assert result.description == "Batch transform tool" assert result.args_schema is not None # Has destination_path parameter @@ -611,7 +604,7 @@ def test_dynamic_query_batch_transform_creation(self): result = handle_batch_transform("batch_transform_tool", resource) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) assert result.name == "batch_transform_tool" assert result.args_schema is not None output_schema = result.output_type.model_json_schema() @@ -652,6 +645,43 @@ def test_dynamic_query_batch_transform_has_both_parameters(self): assert "query" in schema["properties"] assert "destination_path" in schema["properties"] + def test_batch_transform_with_folder_path_prefix_from_settings(self): + """Test that batch transform builds argument_properties from settings.""" + resource = AgentContextResourceConfig( + name="batch_transform_tool", + description="Batch transform tool", + resource_type="context", + index_name="test-index", + folder_path="/test/folder", + settings=AgentContextSettings( + result_count=5, + retrieval_mode=AgentContextRetrievalMode.BATCH_TRANSFORM, + query=AgentContextQuerySetting( + value="transform query", + description="Static query", + variant="static", + ), + web_search_grounding=AgentContextValueSetting(value="enabled"), + output_columns=[ + AgentContextOutputColumn( + name="output_col1", description="First output column" + ), + ], + folder_path_prefix=AgentContextQuerySetting( + value="{batchFolderPrefix}", variant="argument" + ), + ), + is_enabled=True, + ) + + result = handle_batch_transform("batch_transform_tool", resource) + + assert isinstance(result, StructuredToolWithArgumentProperties) + # folder_path_prefix is resolved directly in the wrapper from state, + # not via argument_properties or args_schema + assert "folder_path_prefix" not in result.argument_properties + assert isinstance(result.args_schema, type) + @pytest.mark.asyncio async def test_static_query_batch_transform_uses_predefined_query( self, batch_transform_config @@ -907,3 +937,97 @@ def test_supported_extensions(self, ext): def test_unsupported_extension_still_works(self): """Extensions outside the named set are handled identically.""" assert build_glob_pattern("data", "xlsx") == "data/*.xlsx" + + # --- Trailing file-matching globs stripped --- + + def test_prefix_with_trailing_star(self): + """Trailing /* is stripped since extension is appended separately.""" + assert build_glob_pattern("documents/*", "pdf") == "documents/*.pdf" + + def test_prefix_with_trailing_double_star(self): + """Trailing /** is stripped.""" + assert build_glob_pattern("documents/**", "pdf") == "documents/*.pdf" + + def test_prefix_with_trailing_double_star_star(self): + """Trailing /**/* is stripped.""" + assert build_glob_pattern("documents/**/*", "pdf") == "documents/*.pdf" + + def test_match_all_glob_treated_as_no_prefix(self): + """/**/* is a match-all pattern and should be treated as no prefix.""" + assert build_glob_pattern("/**/*", "pdf") == "**/*.pdf" + + def test_star_slash_star_treated_as_no_prefix(self): + """*/* is a match-all pattern and should be treated as no prefix.""" + assert build_glob_pattern("*/*", "pdf") == "**/*.pdf" + + def test_double_star_slash_star_treated_as_no_prefix(self): + """**/* is a match-all pattern and should be treated as no prefix.""" + assert build_glob_pattern("**/*", "pdf") == "**/*.pdf" + + +class TestNormalizeFolderPrefix: + """Test cases for _normalize_folder_prefix function.""" + + # --- None / empty --- + + def test_none_returns_double_star(self): + assert _normalize_folder_prefix(None) == "**" + + def test_empty_string_returns_double_star(self): + assert _normalize_folder_prefix("") == "**" + + def test_only_slashes_returns_double_star(self): + assert _normalize_folder_prefix("///") == "**" + + # --- Leading/trailing slash stripping --- + + def test_strips_leading_slash(self): + assert _normalize_folder_prefix("/documents") == "documents" + + def test_strips_trailing_slash(self): + assert _normalize_folder_prefix("documents/") == "documents" + + def test_strips_both_slashes(self): + assert _normalize_folder_prefix("/documents/") == "documents" + + # --- Trailing glob stripping --- + + def test_strips_trailing_star(self): + assert _normalize_folder_prefix("documents/*") == "documents" + + def test_strips_trailing_double_star(self): + assert _normalize_folder_prefix("documents/**") == "documents" + + def test_strips_trailing_double_star_star(self): + assert _normalize_folder_prefix("documents/**/*") == "documents" + + def test_nested_prefix_strips_trailing_glob(self): + assert _normalize_folder_prefix("folder/subfolder/*") == "folder/subfolder" + + def test_nested_prefix_strips_trailing_double_star_star(self): + assert _normalize_folder_prefix("folder/subfolder/**/*") == "folder/subfolder" + + # --- Match-all patterns become ** --- + + def test_star_slash_star_returns_double_star(self): + assert _normalize_folder_prefix("*/*") == "**" + + def test_double_star_slash_star_returns_double_star(self): + assert _normalize_folder_prefix("**/*") == "**" + + def test_slash_double_star_slash_star_returns_double_star(self): + assert _normalize_folder_prefix("/**/*") == "**" + + # --- Preserves valid prefixes --- + + def test_simple_prefix(self): + assert _normalize_folder_prefix("folder") == "folder" + + def test_nested_prefix(self): + assert _normalize_folder_prefix("folder/subfolder") == "folder/subfolder" + + def test_double_star_prefix_preserved(self): + assert _normalize_folder_prefix("**/documents") == "**/documents" + + def test_double_star_nested_prefix_preserved(self): + assert _normalize_folder_prefix("**/docs/reports") == "**/docs/reports" diff --git a/uv.lock b/uv.lock index fcff15fd..701cc06c 100644 --- a/uv.lock +++ b/uv.lock @@ -3289,7 +3289,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.13" +version = "2.10.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -3312,9 +3312,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/3a/3a93f5c54078b993e6cecdae6069ebafe122fceb639b1f59ad0aa3f1b765/uipath-2.10.13.tar.gz", hash = "sha256:13795c00dfb7391f248efb6ae4b96f096a4a8131d0df5f8ec0b7f265be1d0e10", size = 2456921, upload-time = "2026-03-13T07:51:16.484Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/28/7eac19f5a20a5658690ad5d30d237e414a288444b30cf65333b26dda9731/uipath-2.10.11.tar.gz", hash = "sha256:28c72ead96f5bff8d4dc721e97f0b546eebc76184f6ab4b4a2c95f6f0e9d4db1", size = 2455334, upload-time = "2026-03-09T14:29:15.275Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/f9/c8745f866a39047f529ee0413aedd8fbf81f7ffd5dd46862cb5e6221d58d/uipath-2.10.13-py3-none-any.whl", hash = "sha256:fc1c8503b9cc3538cf3003cb10e1e3e736529aba4272086066402fde21154b65", size = 357769, upload-time = "2026-03-13T07:51:14.599Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0a/49d0904346ce70440b6aee6a98df1252b58a2d72f2f54db41fd213dcf980/uipath-2.10.11-py3-none-any.whl", hash = "sha256:7187df5536084c2097d7b2340bb99f545a4d914ca5533abf0b21bd2c3822c5b6", size = 357297, upload-time = "2026-03-09T14:29:13.213Z" }, ] [[package]] @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.20" +version = "0.8.21" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -3402,7 +3402,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "uipath", specifier = ">=2.10.0,<2.11.0" }, { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, - { name = "uipath-platform", specifier = ">=0.0.18,<0.1.0" }, + { name = "uipath-platform", specifier = ">=0.0.23,<0.1.0" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, ] provides-extras = ["vertex", "bedrock"]