From 57c308b50a4908f2a869f2a94f544eb3c714e835 Mon Sep 17 00:00:00 2001 From: Andrei Date: Tue, 3 Feb 2026 12:33:27 +0200 Subject: [PATCH] fix: support tools with argument named self --- pyproject.toml | 2 +- .../tools/base_uipath_structured_tool.py | 86 ++++++ src/uipath_langchain/agent/tools/mcp_tool.py | 8 +- .../tools/structured_tool_with_output_type.py | 5 +- .../tools/test_base_uipath_structured_tool.py | 155 ++++++++++ tests/agent/tools/test_tool_factory.py | 272 ++++++++++++++++-- uv.lock | 2 +- 7 files changed, 500 insertions(+), 30 deletions(-) create mode 100644 src/uipath_langchain/agent/tools/base_uipath_structured_tool.py create mode 100644 tests/agent/tools/test_base_uipath_structured_tool.py diff --git a/pyproject.toml b/pyproject.toml index e2df40e6..83a2d672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.5.19" +version = "0.5.20" 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/base_uipath_structured_tool.py b/src/uipath_langchain/agent/tools/base_uipath_structured_tool.py new file mode 100644 index 00000000..fa98ad01 --- /dev/null +++ b/src/uipath_langchain/agent/tools/base_uipath_structured_tool.py @@ -0,0 +1,86 @@ +from inspect import signature +from typing import Any + +from langchain_core.callbacks import ( + AsyncCallbackManagerForToolRun, + CallbackManagerForToolRun, +) +from langchain_core.runnables import RunnableConfig +from langchain_core.tools import StructuredTool +from langchain_core.tools.base import _get_runnable_config_param + + +class BaseUiPathStructuredTool(StructuredTool): + """Base class for UiPath structured tools. + + Extends LangChain's StructuredTool to override the _run and _arun methods. + The only difference is that the self reference variable is renamed, to avoid conflicts with payload keys. + + DO NOT CHANGE ANYTHING IN THESE METHODS. + There are tests that verify the implementations against the upstream LangChain implementations. + + """ + + def _run( + __obj_internal_self__, + *args: Any, + config: RunnableConfig, + run_manager: CallbackManagerForToolRun | None = None, + **kwargs: Any, + ) -> Any: + """Use the tool. + + Args: + *args: Positional arguments to pass to the tool + config: Configuration for the run + run_manager: Optional callback manager to use for the run + **kwargs: Keyword arguments to pass to the tool + + Returns: + The result of the tool execution + """ + if __obj_internal_self__.func: + if run_manager and signature(__obj_internal_self__.func).parameters.get( + "callbacks" + ): + kwargs["callbacks"] = run_manager.get_child() + if config_param := _get_runnable_config_param(__obj_internal_self__.func): + kwargs[config_param] = config + return __obj_internal_self__.func(*args, **kwargs) + msg = "StructuredTool does not support sync invocation." + raise NotImplementedError(msg) + + async def _arun( + __obj_internal_self__, + *args: Any, + config: RunnableConfig, + run_manager: AsyncCallbackManagerForToolRun | None = None, + **kwargs: Any, + ) -> Any: + """Use the tool asynchronously. + + Args: + *args: Positional arguments to pass to the tool + config: Configuration for the run + run_manager: Optional callback manager to use for the run + **kwargs: Keyword arguments to pass to the tool + + Returns: + The result of the tool execution + """ + if __obj_internal_self__.coroutine: + if run_manager and signature( + __obj_internal_self__.coroutine + ).parameters.get("callbacks"): + kwargs["callbacks"] = run_manager.get_child() + if config_param := _get_runnable_config_param( + __obj_internal_self__.coroutine + ): + kwargs[config_param] = config + return await __obj_internal_self__.coroutine(*args, **kwargs) + + # If self.coroutine is None, then this will delegate to the default + # implementation which is expected to delegate to _run on a separate thread. + return await super()._arun( + *args, config=config, run_manager=run_manager, **kwargs + ) diff --git a/src/uipath_langchain/agent/tools/mcp_tool.py b/src/uipath_langchain/agent/tools/mcp_tool.py index ead1a4ac..8ed6bca5 100644 --- a/src/uipath_langchain/agent/tools/mcp_tool.py +++ b/src/uipath_langchain/agent/tools/mcp_tool.py @@ -6,13 +6,17 @@ from typing import Any, AsyncGenerator import httpx -from langchain_core.tools import BaseTool, StructuredTool +from langchain_core.tools import BaseTool from uipath._utils._ssl_context import get_httpx_client_kwargs from uipath.agent.models.agent import AgentMcpResourceConfig, AgentMcpTool from uipath.eval.mocks import mockable from uipath.platform import UiPath from uipath.platform.orchestrator.mcp import McpServer +from uipath_langchain.agent.tools.base_uipath_structured_tool import ( + BaseUiPathStructuredTool, +) + from .utils import sanitize_tool_name @@ -165,7 +169,7 @@ async def tool_fn(**kwargs: Any) -> Any: return tool_fn - tool = StructuredTool( + tool = BaseUiPathStructuredTool( name=tool_name, description=mcp_tool.description, args_schema=mcp_tool.input_schema, diff --git a/src/uipath_langchain/agent/tools/structured_tool_with_output_type.py b/src/uipath_langchain/agent/tools/structured_tool_with_output_type.py index cc5a78d6..55ee66aa 100644 --- a/src/uipath_langchain/agent/tools/structured_tool_with_output_type.py +++ b/src/uipath_langchain/agent/tools/structured_tool_with_output_type.py @@ -1,11 +1,12 @@ from typing import Any -from langchain_core.tools import StructuredTool from pydantic import Field from typing_extensions import override +from .base_uipath_structured_tool import BaseUiPathStructuredTool -class StructuredToolWithOutputType(StructuredTool): + +class StructuredToolWithOutputType(BaseUiPathStructuredTool): output_type: Any = Field(Any, description="Output type.") @override diff --git a/tests/agent/tools/test_base_uipath_structured_tool.py b/tests/agent/tools/test_base_uipath_structured_tool.py new file mode 100644 index 00000000..e56613b0 --- /dev/null +++ b/tests/agent/tools/test_base_uipath_structured_tool.py @@ -0,0 +1,155 @@ +"""Tests for BaseUiPathStructuredTool to ensure it stays in sync with StructuredTool.""" + +from types import CodeType + +import pytest +from langchain_core.tools import StructuredTool +from pydantic import BaseModel + +from uipath_langchain.agent.tools.base_uipath_structured_tool import ( + BaseUiPathStructuredTool, +) + + +def _assert_code_objects_match_except_varnames( + base_code: CodeType, struct_code: CodeType, method_name: str +) -> None: + """Compare two code objects ensuring they're identical except for the first parameter name (self). + + Args: + base_code: Code object from BaseUiPathStructuredTool + struct_code: Code object from StructuredTool + method_name: Name of the method being compared (for error messages) + """ + assert base_code.co_code == struct_code.co_code, ( + f"{method_name}: Bytecode mismatch (length {len(base_code.co_code)} vs {len(struct_code.co_code)})" + ) + assert base_code.co_consts == struct_code.co_consts, ( + f"{method_name}: Constants mismatch: {base_code.co_consts} vs {struct_code.co_consts}" + ) + assert base_code.co_names == struct_code.co_names, ( + f"{method_name}: Names mismatch: {base_code.co_names} vs {struct_code.co_names}" + ) + + base_varnames = list(base_code.co_varnames) + struct_varnames = list(struct_code.co_varnames) + + assert len(base_varnames) == len(struct_varnames), ( + f"{method_name}: Variable count mismatch: {len(base_varnames)} vs {len(struct_varnames)}" + ) + + assert struct_varnames[0] == "self", ( + f"{method_name}: Expected 'self' in StructuredTool but got '{struct_varnames[0]}'" + ) + assert base_varnames[0] == "__obj_internal_self__", ( + f"{method_name}: Expected '__obj_internal_self__' in BaseUiPathStructuredTool " + f"but got '{base_varnames[0]}'" + ) + + assert base_varnames[1:] == struct_varnames[1:], ( + f"{method_name}: Variable names mismatch (excluding first): " + f"{base_varnames[1:]} vs {struct_varnames[1:]}" + ) + + +def test_run_implementation_matches_structured_tool(): + """Verify that _run implementation matches StructuredTool except for the first parameter name (self). + + If this test fails and BaseUiPathStructuredTool._run has NOT been modified, + it means the upstream langchain_core.tools.StructuredTool._run implementation + has changed and BaseUiPathStructuredTool is now out of sync. + + Action required: Update BaseUiPathStructuredTool._run to match the new + StructuredTool._run implementation, keeping only the 'self' -> '__obj_internal_self__' rename. + """ + try: + _assert_code_objects_match_except_varnames( + BaseUiPathStructuredTool._run.__code__, + StructuredTool._run.__code__, + "_run", + ) + except AssertionError as e: + msg = ( + "\n\nIMPLEMENTATION OUT OF SYNC:\n" + "If BaseUiPathStructuredTool._run was NOT modified, this means the upstream " + "langchain_core.tools.StructuredTool._run has changed.\n\n" + "Action required:\n" + " 1. Check the new StructuredTool._run implementation\n" + " 2. Update BaseUiPathStructuredTool._run to match\n" + " 3. Keep ONLY the 'self' -> '__obj_internal_self__' parameter rename\n\n" + ) + raise AssertionError(msg + str(e)) from e + + +def test_arun_implementation_matches_structured_tool(): + """Verify that _arun implementation matches StructuredTool except for the first parameter name (self). + + If this test fails and BaseUiPathStructuredTool._arun has NOT been modified, + it means the upstream langchain_core.tools.StructuredTool._arun implementation + has changed and BaseUiPathStructuredTool is now out of sync. + + Action required: Update BaseUiPathStructuredTool._arun to match the new + StructuredTool._arun implementation, keeping only the 'self' -> '__obj_internal_self__' rename. + """ + try: + _assert_code_objects_match_except_varnames( + BaseUiPathStructuredTool._arun.__code__, + StructuredTool._arun.__code__, + "_arun", + ) + except AssertionError as e: + msg = ( + "\n\nIMPLEMENTATION OUT OF SYNC:\n" + "If BaseUiPathStructuredTool._arun was NOT modified, this means the upstream " + "langchain_core.tools.StructuredTool._arun has changed.\n\n" + "Action required:\n" + " 1. Check the new StructuredTool._arun implementation\n" + " 2. Update BaseUiPathStructuredTool._arun to match\n" + " 3. Keep ONLY the 'self' -> '__obj_internal_self__' parameter rename\n\n" + ) + raise AssertionError(msg + str(e)) from e + + +def test_function_with_self_parameter(): + """Verify that a function with 'self' parameter can be invoked without conflicts.""" + + class Args(BaseModel): + self: str + value: int + + def my_function(self: str, value: int) -> str: + """A function with a 'self' parameter that is not the instance reference.""" + return f"{self}:{value}" + + tool = BaseUiPathStructuredTool( + func=my_function, + name="test_tool", + description="Test tool with self parameter", + args_schema=Args, + ) + + result = tool.invoke({"self": "test", "value": 42}) + assert result == "test:42" + + +@pytest.mark.asyncio +async def test_coroutine_with_self_parameter(): + """Verify that a coroutine with 'self' parameter can be invoked without conflicts.""" + + class Args(BaseModel): + self: str + value: int + + async def my_coroutine(self: str, value: int) -> str: + """A coroutine with a 'self' parameter that is not the instance reference.""" + return f"{self}:{value}" + + tool = BaseUiPathStructuredTool( + coroutine=my_coroutine, + name="test_tool_async", + description="Test async tool with self parameter", + args_schema=Args, + ) + + result = await tool.ainvoke({"self": "async_test", "value": 99}) + assert result == "async_test:99" diff --git a/tests/agent/tools/test_tool_factory.py b/tests/agent/tools/test_tool_factory.py index 90f6e60f..38135247 100644 --- a/tests/agent/tools/test_tool_factory.py +++ b/tests/agent/tools/test_tool_factory.py @@ -1,50 +1,252 @@ """Tests for tool_factory.py module.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from langchain_core.language_models import BaseChatModel from uipath.agent.models.agent import ( + AgentContextQuerySetting, AgentContextResourceConfig, + AgentContextRetrievalMode, AgentContextSettings, + AgentEscalationChannel, + AgentEscalationChannelProperties, + AgentEscalationResourceConfig, + AgentIntegrationToolProperties, + AgentIntegrationToolResourceConfig, + AgentInternalToolProperties, + AgentInternalToolResourceConfig, + AgentInternalToolType, + AgentIxpExtractionResourceConfig, + AgentIxpExtractionToolProperties, + AgentMcpResourceConfig, + AgentMcpTool, AgentProcessToolProperties, AgentProcessToolResourceConfig, + AgentResourceType, AgentSettings, AgentToolType, LowCodeAgentDefinition, ) +from uipath.platform.connections import Connection -from uipath_langchain.agent.tools.tool_factory import create_tools_from_resources +from uipath_langchain.agent.tools.base_uipath_structured_tool import ( + BaseUiPathStructuredTool, +) +from uipath_langchain.agent.tools.tool_factory import ( + _build_tool_for_resource, + create_tools_from_resources, +) + +# Common test data +EMPTY_SCHEMA = {"type": "object", "properties": {}} + + +@pytest.fixture +def mock_uipath_sdk(): + """Create a mock UiPath SDK.""" + with patch("uipath_langchain.agent.tools.integration_tool.UiPath") as mock: + mock.return_value = MagicMock() + yield mock + + +@pytest.fixture +def process_resource() -> AgentProcessToolResourceConfig: + """Create a process tool resource config.""" + return AgentProcessToolResourceConfig( + type=AgentToolType.PROCESS, + name="test_process", + description="Test process description", + input_schema=EMPTY_SCHEMA, + output_schema=EMPTY_SCHEMA, + properties=AgentProcessToolProperties( + process_name="MyProcess", + folder_path="/Shared/MyFolder", + ), + ) + + +@pytest.fixture +def context_resource() -> AgentContextResourceConfig: + """Create a context tool resource config.""" + return AgentContextResourceConfig( + resource_type=AgentResourceType.CONTEXT, + name="test_context", + description="Test context description", + index_name="test_index", + folder_path="/Shared/MyFolder", + settings=AgentContextSettings( + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + result_count=10, + query=AgentContextQuerySetting( + variant="static", + value="test query", + description="test description", + ), + ), + ) + + +@pytest.fixture +def escalation_resource() -> AgentEscalationResourceConfig: + """Create an escalation tool resource config.""" + return AgentEscalationResourceConfig( + resource_type=AgentResourceType.ESCALATION, + name="test_escalation", + description="Test escalation description", + channels=[ + AgentEscalationChannel( + name="test_channel", + type="action_center", + description="Test channel description", + task_title="Test Task", + input_schema=EMPTY_SCHEMA, + output_schema=EMPTY_SCHEMA, + properties=AgentEscalationChannelProperties( + app_name="TestApp", + folder_name="/Shared/MyFolder", + app_version=1, + resource_key="test-key", + ), + recipients=[], + ) + ], + ) + + +@pytest.fixture +def integration_resource() -> AgentIntegrationToolResourceConfig: + """Create an integration tool resource config.""" + return AgentIntegrationToolResourceConfig( + type=AgentToolType.INTEGRATION, + name="test_integration", + description="Test integration description", + input_schema=EMPTY_SCHEMA, + output_schema=EMPTY_SCHEMA, + properties=AgentIntegrationToolProperties( + method="GET", + tool_path="/api/test", + object_name="test_object", + tool_display_name="Test Tool", + tool_description="Test tool description", + connection=Connection( + id="test-connection-id", + name="Test Connection", + element_instance_id=12345, + ), + parameters=[], + ), + ) + + +@pytest.fixture +def internal_resource() -> AgentInternalToolResourceConfig: + """Create an internal tool resource config.""" + return AgentInternalToolResourceConfig( + type=AgentToolType.INTERNAL, + name="test_internal", + description="Test internal description", + input_schema={ + "type": "object", + "properties": { + "analysisTask": {"type": "string"}, + "attachments": {"type": "array"}, + }, + }, + output_schema=EMPTY_SCHEMA, + properties=AgentInternalToolProperties( + tool_type=AgentInternalToolType.ANALYZE_FILES, + ), + ) + + +@pytest.fixture +def ixp_extraction_resource() -> AgentIxpExtractionResourceConfig: + """Create an IXP extraction tool resource config.""" + return AgentIxpExtractionResourceConfig( + type=AgentToolType.IXP, + name="test_extraction", + description="Test extraction description", + input_schema=EMPTY_SCHEMA, + output_schema=EMPTY_SCHEMA, + properties=AgentIxpExtractionToolProperties( + project_name="TestProject", + version_tag="v1.0", + ), + ) + + +@pytest.fixture +def mcp_resource() -> AgentMcpResourceConfig: + """Create an MCP tool resource config with multiple tools.""" + return AgentMcpResourceConfig( + resource_type=AgentResourceType.MCP, + name="test_mcp", + description="Test MCP description", + slug="test-mcp-slug", + folder_path="/Shared/MyFolder", + is_enabled=True, + available_tools=[ + AgentMcpTool( + name="tool1", + description="Tool 1", + input_schema=EMPTY_SCHEMA, + ), + AgentMcpTool( + name="tool2", + description="Tool 2", + input_schema=EMPTY_SCHEMA, + ), + ], + ) + + +@pytest.fixture +def all_resources( + process_resource, + context_resource, + escalation_resource, + integration_resource, + internal_resource, + ixp_extraction_resource, + mcp_resource, +): + """Fixture providing all resource types.""" + return [ + process_resource, + context_resource, + escalation_resource, + integration_resource, + internal_resource, + ixp_extraction_resource, + mcp_resource, + ] + + +def assert_tool_is_base_uipath(tool): + """Helper to assert tool is BaseUiPathStructuredTool instance.""" + if isinstance(tool, list): + for t in tool: + assert isinstance(t, BaseUiPathStructuredTool) + else: + assert tool is not None + assert isinstance(tool, BaseUiPathStructuredTool) @pytest.mark.asyncio class TestCreateToolsFromResources: """Test cases for create_tools_from_resources function.""" - async def test_only_enabled_tools_returned(self): + async def test_only_enabled_tools_returned( + self, process_resource, context_resource + ): """Test that only enabled tools are returned from resources.""" - enabled_process_tool = AgentProcessToolResourceConfig( - type=AgentToolType.PROCESS, - name="EnabledProcess", - description="Enabled process tool", - input_schema={"type": "object", "properties": {}}, - output_schema={"type": "object", "properties": {}}, - properties=AgentProcessToolProperties( - process_name="EnabledProcess", - folder_path="/Shared/EnabledSolution", - ), - is_enabled=True, - ) + enabled_process_tool = process_resource + enabled_process_tool.is_enabled = True + enabled_process_tool.name = "EnabledProcess" - disabled_context_tool = AgentContextResourceConfig( - name="disabled_context", - description="Disabled context tool", - resource_type="context", - index_name="test-index", - folder_path="/test/folder", - settings=Mock(spec=AgentContextSettings), - is_enabled=False, - ) + disabled_context_tool = context_resource + disabled_context_tool.is_enabled = False agent = LowCodeAgentDefinition( input_schema={"type": "object", "properties": {}}, @@ -59,3 +261,25 @@ async def test_only_enabled_tools_returned(self): assert len(tools) == 1 assert tools[0].name == "EnabledProcess" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "resource_fixture", + [ + "process_resource", + "context_resource", + "escalation_resource", + "integration_resource", + "internal_resource", + "ixp_extraction_resource", + "mcp_resource", + ], + ) + async def test_resource_produces_base_uipath_tool( + self, resource_fixture, mock_uipath_sdk, request + ): + """Test that each resource type produces BaseUiPathStructuredTool instance.""" + resource = request.getfixturevalue(resource_fixture) + mock_llm = AsyncMock(spec=BaseChatModel) + tool = await _build_tool_for_resource(resource, mock_llm) + assert_tool_is_base_uipath(tool) diff --git a/uv.lock b/uv.lock index 62b21165..650325df 100644 --- a/uv.lock +++ b/uv.lock @@ -3297,7 +3297,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.5.19" +version = "0.5.20" source = { editable = "." } dependencies = [ { name = "httpx" },