Skip to content
Merged
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.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"
Expand Down
86 changes: 86 additions & 0 deletions src/uipath_langchain/agent/tools/base_uipath_structured_tool.py
Original file line number Diff line number Diff line change
@@ -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
)
8 changes: 6 additions & 2 deletions src/uipath_langchain/agent/tools/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
155 changes: 155 additions & 0 deletions tests/agent/tools/test_base_uipath_structured_tool.py
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading