Skip to content
Draft
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
100 changes: 100 additions & 0 deletions src/uipath_langchain/agent/tools/internal_tools/get_case_state_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Internal tool that fetches the current state of a PIMs case instance."""

from typing import Any

from langchain_core.language_models import BaseChatModel
from langchain_core.tools import StructuredTool
from uipath.agent.models.agent import AgentInternalToolResourceConfig
from uipath.eval.mocks import mockable
from uipath.platform import UiPath
from uipath.platform.errors import EnrichedException
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain.agent.exceptions import raise_for_enriched
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
from uipath_langchain.agent.tools.structured_tool_with_argument_properties import (
StructuredToolWithArgumentProperties,
)
from uipath_langchain.agent.tools.utils import sanitize_tool_name

PIMS_INSTANCE_PATH = "pims_/api/v1/instances/{instance_id}"

_GET_CASE_STATE_ERRORS: dict[
tuple[int, str | None], tuple[str, UiPathErrorCategory]
] = {
(404, None): (
"Case instance not found for tool '{tool}': {message}",
UiPathErrorCategory.USER,
),
(401, None): (
"Unauthorized when fetching case state for tool '{tool}': {message}",
UiPathErrorCategory.SYSTEM,
),
(403, None): (
"Forbidden when fetching case state for tool '{tool}': {message}",
UiPathErrorCategory.USER,
),
}


def create_get_case_state_tool(
resource: AgentInternalToolResourceConfig, llm: BaseChatModel

Check warning on line 41 in src/uipath_langchain/agent/tools/internal_tools/get_case_state_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "llm".

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AZ5rb8UiamSfYn3kyAf4&open=AZ5rb8UiamSfYn3kyAf4&pullRequest=879
) -> StructuredTool:
"""Create the GetCaseState internal tool.

Calls the PIMs instances endpoint with the agent's UiPath credentials and
folder context. The folder key is taken from the optional ``folderKey``
argument when provided, otherwise from the runtime's ``UIPATH_FOLDER_KEY``
environment variable via :class:`uipath.platform.common.ApiClient`.
"""
tool_name = sanitize_tool_name(resource.name)
input_model = create_model(resource.input_schema)
output_model = create_model(resource.output_schema)

@mockable(
name=resource.name,
description=resource.description,
input_schema=input_model.model_json_schema(),
output_schema=output_model.model_json_schema(),
)
async def tool_fn(**kwargs: Any) -> Any:
instance_id = kwargs.get("instanceId")
if not instance_id:
raise ValueError("Argument 'instanceId' is required")

folder_key_override = kwargs.get("folderKey")

client = UiPath()
url = PIMS_INSTANCE_PATH.format(instance_id=instance_id)
request_kwargs: dict[str, Any] = {}
if folder_key_override:
request_kwargs["headers"] = {"x-uipath-folderkey": folder_key_override}
else:
request_kwargs["include_folder_headers"] = True

try:
response = await client.api_client.request_async(
"GET", url, **request_kwargs
)
except EnrichedException as e:
raise_for_enriched(
e, _GET_CASE_STATE_ERRORS, title=tool_name, tool=tool_name
)
raise

return response.json()

return StructuredToolWithArgumentProperties(
name=tool_name,
description=resource.description,
args_schema=input_model,
coroutine=tool_fn,
output_type=output_model,
argument_properties=resource.argument_properties,
metadata={
"tool_type": resource.type.lower(),
"display_name": tool_name,
"args_schema": input_model,
"output_schema": output_model,
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from .analyze_files_tool import create_analyze_file_tool
from .batch_transform_tool import create_batch_transform_tool
from .deeprag_tool import create_deeprag_tool
from .get_case_state_tool import create_get_case_state_tool

_INTERNAL_TOOL_HANDLERS: dict[
AgentInternalToolType,
Expand All @@ -37,6 +38,7 @@
AgentInternalToolType.ANALYZE_FILES: create_analyze_file_tool,
AgentInternalToolType.DEEP_RAG: create_deeprag_tool,
AgentInternalToolType.BATCH_TRANSFORM: create_batch_transform_tool,
AgentInternalToolType.GET_CASE_STATE: create_get_case_state_tool,
}


Expand Down
151 changes: 151 additions & 0 deletions tests/agent/tools/internal_tools/test_get_case_state_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Tests for get_case_state_tool.py module."""

from unittest.mock import AsyncMock, Mock, patch

import pytest
from uipath.agent.models.agent import (
AgentInternalGetCaseStateToolProperties,
AgentInternalToolResourceConfig,
AgentInternalToolType,
)

from uipath_langchain.agent.exceptions import AgentRuntimeError
from uipath_langchain.agent.tools.internal_tools.get_case_state_tool import (
PIMS_INSTANCE_PATH,
create_get_case_state_tool,
)


class TestCreateGetCaseStateTool:
"""Test cases for create_get_case_state_tool function."""

@pytest.fixture
def mock_llm(self):
return AsyncMock()

@pytest.fixture
def resource_config(self):
input_schema = {
"type": "object",
"properties": {
"instanceId": {"type": "string"},
"folderKey": {"type": "string"},
},
"required": ["instanceId"],
}
output_schema = {
"type": "object",
"properties": {"state": {"type": "string"}},
"required": ["state"],
}
properties = AgentInternalGetCaseStateToolProperties(
tool_type=AgentInternalToolType.GET_CASE_STATE
)
return AgentInternalToolResourceConfig(
name="get_case_state",
description="Fetch the current state of a case by instance id.",
input_schema=input_schema,
output_schema=output_schema,
properties=properties,
)

@staticmethod
def _mock_uipath_response(mock_uipath_class, payload):
response = Mock()
response.json.return_value = payload
mock_uipath = Mock()
mock_uipath.api_client.request_async = AsyncMock(return_value=response)
mock_uipath_class.return_value = mock_uipath
return mock_uipath

@patch(
"uipath_langchain.agent.tools.internal_tools.get_case_state_tool.mockable",
lambda **kwargs: lambda f: f,
)
@patch("uipath_langchain.agent.tools.internal_tools.get_case_state_tool.UiPath")
async def test_explicit_folder_key_sends_header(
self, mock_uipath_class, resource_config, mock_llm
):
mock_uipath = self._mock_uipath_response(
mock_uipath_class, {"state": "Resolved"}
)

tool = create_get_case_state_tool(resource_config, mock_llm)
assert tool.coroutine is not None
result = await tool.coroutine(instanceId="abc-123", folderKey="folder-xyz")

assert result == {"state": "Resolved"}
mock_uipath.api_client.request_async.assert_awaited_once()
args, kwargs = mock_uipath.api_client.request_async.call_args
assert args == ("GET", PIMS_INSTANCE_PATH.format(instance_id="abc-123"))
assert kwargs == {"headers": {"x-uipath-folderkey": "folder-xyz"}}

@patch(
"uipath_langchain.agent.tools.internal_tools.get_case_state_tool.mockable",
lambda **kwargs: lambda f: f,
)
@patch("uipath_langchain.agent.tools.internal_tools.get_case_state_tool.UiPath")
async def test_falls_back_to_runtime_folder_context(
self, mock_uipath_class, resource_config, mock_llm
):
mock_uipath = self._mock_uipath_response(
mock_uipath_class, {"state": "InProgress"}
)

tool = create_get_case_state_tool(resource_config, mock_llm)
assert tool.coroutine is not None
result = await tool.coroutine(instanceId="abc-123")

assert result == {"state": "InProgress"}
args, kwargs = mock_uipath.api_client.request_async.call_args
assert args == ("GET", PIMS_INSTANCE_PATH.format(instance_id="abc-123"))
assert kwargs == {"include_folder_headers": True}

@patch(
"uipath_langchain.agent.tools.internal_tools.get_case_state_tool.mockable",
lambda **kwargs: lambda f: f,
)
@patch("uipath_langchain.agent.tools.internal_tools.get_case_state_tool.UiPath")
async def test_missing_instance_id_raises(
self, mock_uipath_class, resource_config, mock_llm
):
self._mock_uipath_response(mock_uipath_class, {})

tool = create_get_case_state_tool(resource_config, mock_llm)
assert tool.coroutine is not None
with pytest.raises(ValueError, match="instanceId"):
await tool.coroutine(folderKey="folder-xyz")

@patch(
"uipath_langchain.agent.tools.internal_tools.get_case_state_tool.mockable",
lambda **kwargs: lambda f: f,
)
@patch("uipath_langchain.agent.tools.internal_tools.get_case_state_tool.UiPath")
async def test_404_maps_to_agent_runtime_error(
self,
mock_uipath_class,
resource_config,
mock_llm,
make_enriched_exception,
):
mock_uipath = Mock()
mock_uipath.api_client.request_async = AsyncMock(
side_effect=make_enriched_exception(404, body="instance not found")
)
mock_uipath_class.return_value = mock_uipath

tool = create_get_case_state_tool(resource_config, mock_llm)
assert tool.coroutine is not None
with pytest.raises(AgentRuntimeError):
await tool.coroutine(instanceId="missing-id")

def test_factory_registered_in_handlers(self):
from uipath_langchain.agent.tools.internal_tools.internal_tool_factory import (
_INTERNAL_TOOL_HANDLERS,
)

assert AgentInternalToolType.GET_CASE_STATE in _INTERNAL_TOOL_HANDLERS
assert (
_INTERNAL_TOOL_HANDLERS[AgentInternalToolType.GET_CASE_STATE]
is create_get_case_state_tool
)
Loading