diff --git a/pyproject.toml b/pyproject.toml index 9e7b3eae..d8dff483 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-langchain" -version = "0.1.44" +version = "0.1.45" 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.2.44, <2.3.0", + "uipath>=2.2.45,<2.3.0", "langgraph>=1.0.0, <2.0.0", "langchain-core>=1.0.0, <2.0.0", "aiosqlite==0.21.0", diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index 5f63a033..2cc0fc1d 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -77,6 +77,7 @@ def create_agent( llm_tools: list[BaseTool] = [*agent_tools, *flow_control_tools] init_node = create_init_node(messages, input_schema) + tool_nodes = create_tool_node(agent_tools) tool_nodes_with_guardrails = create_tools_guardrails_subgraph( tool_nodes, guardrails diff --git a/src/uipath_langchain/agent/tools/__init__.py b/src/uipath_langchain/agent/tools/__init__.py index dad6aa93..f851bdeb 100644 --- a/src/uipath_langchain/agent/tools/__init__.py +++ b/src/uipath_langchain/agent/tools/__init__.py @@ -1,6 +1,7 @@ """Tool creation and management for LowCode agents.""" from .context_tool import create_context_tool +from .escalation_tool import create_escalation_tool from .integration_tool import create_integration_tool from .mcp_tool import create_mcp_tools from .process_tool import create_process_tool @@ -15,6 +16,7 @@ "create_context_tool", "create_process_tool", "create_integration_tool", + "create_escalation_tool", "create_mcp_tools", "UiPathToolNode", "ToolWrapperMixin", diff --git a/src/uipath_langchain/agent/tools/context_tool.py b/src/uipath_langchain/agent/tools/context_tool.py index 23026198..90012323 100644 --- a/src/uipath_langchain/agent/tools/context_tool.py +++ b/src/uipath_langchain/agent/tools/context_tool.py @@ -1,12 +1,24 @@ """Context tool creation for semantic index retrieval.""" +import uuid from typing import Any from langchain_core.documents import Document from langchain_core.tools import StructuredTool +from langgraph.types import interrupt from pydantic import BaseModel, Field -from uipath.agent.models.agent import AgentContextResourceConfig +from uipath.agent.models.agent import ( + AgentContextResourceConfig, + AgentContextRetrievalMode, +) from uipath.eval.mocks import mockable +from uipath.platform.common import CreateBatchTransform, CreateDeepRag +from uipath.platform.context_grounding import ( + BatchTransformOutputColumn, + BatchTransformResponse, + CitationMode, + DeepRagResponse, +) from uipath_langchain.retrievers import ContextGroundingRetriever @@ -16,6 +28,18 @@ def create_context_tool(resource: AgentContextResourceConfig) -> StructuredTool: tool_name = sanitize_tool_name(resource.name) + retrieval_mode = resource.settings.retrieval_mode.lower() + if retrieval_mode == AgentContextRetrievalMode.DEEP_RAG.value.lower(): + return handle_deep_rag(tool_name, resource) + elif retrieval_mode == AgentContextRetrievalMode.BATCH_TRANSFORM.value.lower(): + return handle_batch_transform(tool_name, resource) + else: + return handle_semantic_search(tool_name, resource) + + +def handle_semantic_search( + tool_name: str, resource: AgentContextResourceConfig +) -> StructuredTool: retriever = ContextGroundingRetriever( index_name=resource.index_name, folder_path=resource.folder_path, @@ -52,3 +76,128 @@ async def context_tool_fn(query: str) -> dict[str, Any]: coroutine=context_tool_fn, output_type=output_model, ) + + +def handle_deep_rag( + tool_name: str, resource: AgentContextResourceConfig +) -> StructuredTool: + ensure_valid_fields(resource) + # needed for type checking + assert resource.settings.query is not None + assert resource.settings.query.value is not None + + index_name = resource.index_name + prompt = resource.settings.query.value + if not resource.settings.citation_mode: + raise ValueError("Citation mode is required for Deep RAG") + citation_mode = CitationMode(resource.settings.citation_mode.value) + + input_model = None + output_model = DeepRagResponse + + @mockable( + name=resource.name, + description=resource.description, + input_schema=input_model, + output_schema=output_model.model_json_schema(), + example_calls=[], # Examples cannot be provided for context. + ) + async def context_tool_fn() -> dict[str, Any]: + # TODO: add glob pattern support + return interrupt( + CreateDeepRag( + name=f"task-{uuid.uuid4()}", + index_name=index_name, + prompt=prompt, + citation_mode=citation_mode, + ) + ) + + return StructuredToolWithOutputType( + name=tool_name, + description=resource.description, + args_schema=input_model, + coroutine=context_tool_fn, + output_type=output_model, + ) + + +def handle_batch_transform( + tool_name: str, resource: AgentContextResourceConfig +) -> StructuredTool: + ensure_valid_fields(resource) + + # needed for type checking + assert resource.settings.query is not None + assert resource.settings.query.value is not None + + index_name = resource.index_name + prompt = resource.settings.query.value + + index_folder_path = resource.folder_path + if not resource.settings.web_search_grounding: + raise ValueError("Web search grounding field is required for Batch Transform") + enable_web_search_grounding = ( + resource.settings.web_search_grounding.value.lower() == "enabled" + ) + + batch_transform_output_columns: list[BatchTransformOutputColumn] = [] + if (output_columns := resource.settings.output_columns) is None or not len( + output_columns + ): + raise ValueError( + "Batch transform requires at least one output column to be specified in settings.output_columns" + ) + + for column in output_columns: + batch_transform_output_columns.append( + BatchTransformOutputColumn( + name=column.name, + description=column.description, + ) + ) + + class BatchTransformSchemaModel(BaseModel): + destination_path: str = Field( + ..., + description="The relative file path destination for the modified csv file", + ) + + input_model = BatchTransformSchemaModel + output_model = BatchTransformResponse + + @mockable( + name=resource.name, + description=resource.description, + input_schema=input_model.model_json_schema(), + output_schema=output_model.model_json_schema(), + example_calls=[], # Examples cannot be provided for context. + ) + async def context_tool_fn(destination_path: str) -> dict[str, Any]: + # TODO: storage_bucket_folder_path_prefix support + return interrupt( + CreateBatchTransform( + name=f"task-{uuid.uuid4()}", + index_name=index_name, + prompt=prompt, + destination_path=destination_path, + index_folder_path=index_folder_path, + enable_web_search_grounding=enable_web_search_grounding, + output_columns=batch_transform_output_columns, + ) + ) + + return StructuredToolWithOutputType( + name=tool_name, + description=resource.description, + args_schema=input_model, + coroutine=context_tool_fn, + output_type=output_model, + ) + + +def ensure_valid_fields(resource_config: AgentContextResourceConfig): + if not resource_config.settings.query: + raise ValueError("Query object is required") + if not resource_config.settings.query.value: + raise ValueError("Query prompt is required") diff --git a/tests/agent/tools/test_context_tool.py b/tests/agent/tools/test_context_tool.py new file mode 100644 index 00000000..5373bbd6 --- /dev/null +++ b/tests/agent/tools/test_context_tool.py @@ -0,0 +1,337 @@ +"""Tests for context_tool.py module.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from langchain_core.documents import Document +from uipath.agent.models.agent import ( + AgentContextQuerySetting, + AgentContextResourceConfig, + AgentContextRetrievalMode, + AgentContextSettings, + AgentContextValueSetting, +) +from uipath.platform.context_grounding import CitationMode, DeepRagResponse + +from uipath_langchain.agent.tools.context_tool import ( + create_context_tool, + handle_deep_rag, + handle_semantic_search, +) +from uipath_langchain.agent.tools.structured_tool_with_output_type import ( + StructuredToolWithOutputType, +) + + +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, + 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="variant", + ), + citation_mode=citation_mode_value, + ), + is_enabled=True, + ) + + return _create_config + + def test_successful_deep_rag_creation(self, base_resource_config): + """Test successful creation of Deep RAG tool with all required fields.""" + resource = base_resource_config( + citation_mode_value=AgentContextValueSetting(value="Inline"), + query_value="some query", + ) + + result = handle_deep_rag("test_deep_rag", resource) + + assert isinstance(result, StructuredToolWithOutputType) + assert result.name == "test_deep_rag" + assert result.description == "Test Deep RAG tool" + assert result.args_schema is None + assert result.output_type == DeepRagResponse + + def test_missing_query_object_raises_error(self, base_resource_config): + """Test that missing query object raises ValueError.""" + resource = base_resource_config(query_value=None) + resource.settings.query = None + + with pytest.raises(ValueError, match="Query object is required"): + handle_deep_rag("test_deep_rag", resource) + + def test_missing_query_value_raises_error(self, base_resource_config): + """Test that missing query.value raises ValueError.""" + resource = base_resource_config() + resource.settings.query.value = None + + with pytest.raises(ValueError, match="Query prompt is required"): + handle_deep_rag("test_deep_rag", resource) + + def test_missing_citation_mode_raises_error(self, base_resource_config): + """Test that missing citation_mode raises ValueError.""" + resource = base_resource_config( + query_value="some query", citation_mode_value=None + ) + resource.settings.citation_mode = None + + with pytest.raises(ValueError, match="Citation mode is required for Deep RAG"): + handle_deep_rag("test_deep_rag", resource) + + @pytest.mark.parametrize( + "citation_mode_value,expected_enum", + [ + (AgentContextValueSetting(value="Inline"), CitationMode.INLINE), + (AgentContextValueSetting(value="Skip"), CitationMode.SKIP), + ], + ) + def test_citation_mode_conversion( + self, base_resource_config, citation_mode_value, expected_enum + ): + """Test that citation mode is correctly converted to CitationMode enum.""" + resource = base_resource_config( + query_value="some query", citation_mode_value=citation_mode_value + ) + + result = handle_deep_rag("test_deep_rag", resource) + + assert isinstance(result, StructuredToolWithOutputType) + + def test_tool_name_preserved(self, base_resource_config): + """Test that the sanitized tool name is correctly applied.""" + resource = base_resource_config( + name="My Deep RAG Tool", + citation_mode_value=AgentContextValueSetting(value="Inline"), + query_value="some query", + ) + + result = handle_deep_rag("my_deep_rag_tool", resource) + + assert result.name == "my_deep_rag_tool" + + def test_tool_description_preserved(self, base_resource_config): + """Test that the tool description is correctly preserved.""" + custom_description = "Custom description for Deep RAG retrieval" + resource = base_resource_config( + description=custom_description, + citation_mode_value=AgentContextValueSetting(value="Inline"), + query_value="some query", + ) + + result = handle_deep_rag("test_tool", resource) + + assert result.description == custom_description + + @pytest.mark.asyncio + async def test_tool_with_different_citation_modes(self, base_resource_config): + """Test tool creation and invocation with different citation modes.""" + for mode_value, expected_mode in [ + ("Inline", CitationMode.INLINE), + ("Skip", CitationMode.SKIP), + ]: + resource = base_resource_config( + query_value="test query", + citation_mode_value=AgentContextValueSetting(value=mode_value), + ) + tool = handle_deep_rag("test_tool", resource) + + with patch( + "uipath_langchain.agent.tools.context_tool.interrupt" + ) as mock_interrupt: + mock_interrupt.return_value = {"mocked": "response"} + assert tool.coroutine is not None + await tool.coroutine() + + call_args = mock_interrupt.call_args[0][0] + assert call_args.citation_mode == expected_mode + + @pytest.mark.asyncio + async def test_unique_task_names_on_multiple_invocations( + self, base_resource_config + ): + """Test that each tool invocation generates a unique task name.""" + resource = base_resource_config( + query_value="test query", + citation_mode_value=AgentContextValueSetting(value="Inline"), + ) + tool = handle_deep_rag("test_tool", resource) + + task_names = [] + with patch( + "uipath_langchain.agent.tools.context_tool.interrupt" + ) as mock_interrupt: + mock_interrupt.return_value = {"mocked": "response"} + + # Invoke the tool multiple times + assert tool.coroutine is not None + for _ in range(3): + await tool.coroutine() + call_args = mock_interrupt.call_args[0][0] + task_names.append(call_args.name) + + # Verify all task names are unique + assert len(task_names) == len(set(task_names)) + # Verify all have task- prefix + assert all(name.startswith("task-") for name in task_names) + + +class TestCreateContextTool: + """Test cases for create_context_tool function.""" + + @pytest.fixture + def semantic_search_config(self): + """Fixture for semantic search configuration.""" + return AgentContextResourceConfig( + 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, + ), + is_enabled=True, + ) + + @pytest.fixture + def deep_rag_config(self): + """Fixture for deep RAG configuration.""" + return AgentContextResourceConfig( + 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, + ) + + def test_create_semantic_search_tool(self, semantic_search_config): + """Test that semantic search retrieval mode creates semantic search tool.""" + result = create_context_tool(semantic_search_config) + + assert isinstance(result, StructuredToolWithOutputType) + assert result.name == "test_semantic_search" + assert result.args_schema is not None # Semantic search has input schema + + 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 result.name == "test_deep_rag" + assert result.args_schema is None # Deep RAG has no input schema + assert result.output_type == DeepRagResponse + + 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" + result = create_context_tool(deep_rag_config) + assert isinstance(result, StructuredToolWithOutputType) + + # Test with mixed case + deep_rag_config.settings.retrieval_mode = "Deep_Rag" + result = create_context_tool(deep_rag_config) + assert isinstance(result, StructuredToolWithOutputType) + + +class TestHandleSemanticSearch: + """Test cases for handle_semantic_search function.""" + + @pytest.fixture + def semantic_config(self): + """Fixture for semantic search configuration.""" + return AgentContextResourceConfig( + 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, + ), + is_enabled=True, + ) + + def test_semantic_search_tool_creation(self, semantic_config): + """Test successful creation of semantic search tool.""" + result = handle_semantic_search("semantic_tool", semantic_config) + + assert isinstance(result, StructuredToolWithOutputType) + assert result.name == "semantic_tool" + assert result.description == "Semantic search tool" + assert result.args_schema is not None + + def test_semantic_search_has_query_parameter(self, semantic_config): + """Test that semantic search tool has query parameter in schema.""" + result = handle_semantic_search("semantic_tool", semantic_config) + + # Check that the input schema has a query field + assert result.args_schema is not None + assert hasattr(result.args_schema, "model_json_schema") + schema = result.args_schema.model_json_schema() + assert "properties" in schema + assert "query" in schema["properties"] + assert schema["properties"]["query"]["type"] == "string" + + @pytest.mark.asyncio + async def test_semantic_search_returns_documents(self, semantic_config): + """Test that semantic search tool returns documents.""" + tool = handle_semantic_search("semantic_tool", semantic_config) + + # Mock the retriever + mock_documents = [ + Document(page_content="Test content 1", metadata={"source": "doc1"}), + Document(page_content="Test content 2", metadata={"source": "doc2"}), + ] + + with patch( + "uipath_langchain.agent.tools.context_tool.ContextGroundingRetriever" + ) as mock_retriever_class: + mock_retriever = AsyncMock() + mock_retriever.ainvoke.return_value = mock_documents + mock_retriever_class.return_value = mock_retriever + + # Recreate the tool with mocked retriever + tool = handle_semantic_search("semantic_tool", semantic_config) + assert tool.coroutine is not None + result = await tool.coroutine(query="test query") + + assert "documents" in result + assert len(result["documents"]) == 2 + assert result["documents"][0].page_content == "Test content 1" diff --git a/uv.lock b/uv.lock index d68e3d19..bb0a0bd4 100644 --- a/uv.lock +++ b/uv.lock @@ -3219,7 +3219,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.2.44" +version = "2.2.45" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -3239,9 +3239,9 @@ dependencies = [ { name = "uipath-core" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/79/2b261df31c2265c724b94a389586250075be619c40a47982fe0683de83eb/uipath-2.2.44.tar.gz", hash = "sha256:170accf02e5d8f5c96e2a501d1a8179810d9d37296b67675d39be080de46b252", size = 3431301, upload-time = "2025-12-23T13:38:07.597Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/c4/043f79fbad08a38b0f530e20eebfbd144c1d3664e9b6c23809534dcb9fb5/uipath-2.2.45.tar.gz", hash = "sha256:00849624dd29fb3397ce767f56ed75591d1b7cbfba3f8f2f3a88fa1d4e4e1f9c", size = 3431419, upload-time = "2025-12-24T11:25:52.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/6a/fbcd2389d0db64c0f998a73347d7ae0da7ea96525ec75f7ad31f7e8108a4/uipath-2.2.44-py3-none-any.whl", hash = "sha256:145cc0c84ccd44bac5f82ff330799556a59cdcc8854be8b2ca75497510770d98", size = 398294, upload-time = "2025-12-23T13:38:05.615Z" }, + { url = "https://files.pythonhosted.org/packages/16/e9/3ad8b80d9f548f855f7f04a6c9a027979c5c32dcd2118bab0db58fd6d968/uipath-2.2.45-py3-none-any.whl", hash = "sha256:b817422c4ed8522293d207568870a1364221f8299c3b5f187b365a8af4fbcaf2", size = 398347, upload-time = "2025-12-24T11:25:50.595Z" }, ] [[package]] @@ -3260,7 +3260,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.1.44" +version = "0.1.45" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -3323,7 +3323,7 @@ requires-dist = [ { name = "openinference-instrumentation-langchain", specifier = ">=0.1.56" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "uipath", specifier = ">=2.2.44,<2.3.0" }, + { name = "uipath", specifier = ">=2.2.45,<2.3.0" }, ] provides-extras = ["vertex", "bedrock"]