diff --git a/src/uipath_langchain/agent/tools/internal_tools/_pims_read_tool_factory.py b/src/uipath_langchain/agent/tools/internal_tools/_pims_read_tool_factory.py new file mode 100644 index 000000000..bc374812b --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/_pims_read_tool_factory.py @@ -0,0 +1,136 @@ +"""Shared factory for PIMs read tools. + +All read-only PIMs tools (case state, case plan, case entity, execution trace) +share the same shape: ``GET`` a PIMs endpoint scoped by ``instanceId``, accept +an optional ``folderKey`` override, return the parsed JSON body, and map known +HTTP errors to friendly :class:`AgentRuntimeError` instances. Only the path +template and the human-readable subject (e.g. "case plan") differ per tool. + +This module provides that shared builder. Each per-tool file collapses to a +path constant plus a thin call to :func:`build_pims_read_tool`. +""" + +from typing import Any + +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 + + +def _build_error_map( + subject: str, +) -> dict[tuple[int, str | None], tuple[str, UiPathErrorCategory]]: + """Build the standard PIMs-read error map for the given subject. + + ``subject`` is the noun used in 401/403 messages (e.g. "case plan", + "execution trace"). The 404 message stays generic since the missing thing + is always the case instance itself. + """ + return { + (404, None): ( + "Case instance not found for tool '{tool}': {message}", + UiPathErrorCategory.USER, + ), + (401, None): ( + f"Unauthorized when fetching {subject} for tool '{{tool}}': {{message}}", + UiPathErrorCategory.SYSTEM, + ), + (403, None): ( + f"Forbidden when fetching {subject} for tool '{{tool}}': {{message}}", + UiPathErrorCategory.USER, + ), + } + + +def build_pims_read_tool( + resource: AgentInternalToolResourceConfig, + *, + path_template: str, + subject: str, +) -> StructuredTool: + """Build a read-only PIMs GET tool. + + The tool takes an LLM-supplied ``instanceId`` and an optional ``folderKey`` + override, calls the PIMs endpoint at ``path_template`` (with the + ``{instance_id}`` placeholder filled in), and returns the parsed JSON body. + Known 4xx errors map to friendly :class:`AgentRuntimeError` instances. + + Args: + resource: The tool resource configuration (name, description, input + and output schemas, argument properties). + path_template: The PIMs path with an ``{instance_id}`` placeholder + (e.g. ``"pims_/api/v1/cases/{instance_id}/case-rules"``). + subject: The noun used in 401/403 error messages + (e.g. ``"case plan"``, ``"execution trace"``). + + Returns: + A :class:`StructuredTool` ready to register in the internal-tool + factory map. + + Notes: + The folder key is taken from the optional ``folderKey`` argument when + provided, otherwise from the runtime's ``UIPATH_FOLDER_KEY`` env var + 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) + error_map = _build_error_map(subject) + + @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 = path_template.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, error_map, 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, + }, + ) diff --git a/src/uipath_langchain/agent/tools/internal_tools/get_case_entity_tool.py b/src/uipath_langchain/agent/tools/internal_tools/get_case_entity_tool.py new file mode 100644 index 000000000..d326b244e --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/get_case_entity_tool.py @@ -0,0 +1,29 @@ +"""Internal tool that fetches the case entity (business object, documents, comments) for a PIMs case.""" + +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import StructuredTool +from uipath.agent.models.agent import AgentInternalToolResourceConfig + +from ._pims_read_tool_factory import build_pims_read_tool + +PIMS_CASE_ENTITY_PATH = "pims_/api/v1/cases/{instance_id}/case-json" + + +def create_get_case_entity_tool( + resource: AgentInternalToolResourceConfig, llm: BaseChatModel +) -> StructuredTool: + """Create the GetCaseEntity internal tool. + + Returns the case-json payload from the PIMs ``case-json`` endpoint. Shared + HTTP/auth/folder/error logic lives in :func:`build_pims_read_tool`. + + Note: as of testing on alpha, ``case-json`` returns the case **definition** + (id, version, metadata, stages) rather than the business object / linked + documents / comments. The "entity" naming is provisional pending the CM + team confirming the right surface. + """ + return build_pims_read_tool( + resource, + path_template=PIMS_CASE_ENTITY_PATH, + subject="case entity", + ) diff --git a/src/uipath_langchain/agent/tools/internal_tools/get_case_plan_tool.py b/src/uipath_langchain/agent/tools/internal_tools/get_case_plan_tool.py new file mode 100644 index 000000000..a6e52d68d --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/get_case_plan_tool.py @@ -0,0 +1,26 @@ +"""Internal tool that fetches the case plan (stages, tasks, rules) for a PIMs case.""" + +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import StructuredTool +from uipath.agent.models.agent import AgentInternalToolResourceConfig + +from ._pims_read_tool_factory import build_pims_read_tool + +PIMS_CASE_PLAN_PATH = "pims_/api/v1/cases/{instance_id}/case-rules" + + +def create_get_case_plan_tool( + resource: AgentInternalToolResourceConfig, llm: BaseChatModel +) -> StructuredTool: + """Create the GetCasePlan internal tool. + + Returns the full case plan from the PIMs ``case-rules`` endpoint: every + stage and task with entry/exit rules, ``isRequired``, and + ``shouldRunOnlyOnce``. Shared HTTP/auth/folder/error logic lives in + :func:`build_pims_read_tool`. + """ + return build_pims_read_tool( + resource, + path_template=PIMS_CASE_PLAN_PATH, + subject="case plan", + ) diff --git a/src/uipath_langchain/agent/tools/internal_tools/get_case_state_tool.py b/src/uipath_langchain/agent/tools/internal_tools/get_case_state_tool.py new file mode 100644 index 000000000..c6c19cd59 --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/get_case_state_tool.py @@ -0,0 +1,25 @@ +"""Internal tool that fetches the current state of a PIMs case instance.""" + +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import StructuredTool +from uipath.agent.models.agent import AgentInternalToolResourceConfig + +from ._pims_read_tool_factory import build_pims_read_tool + +PIMS_INSTANCE_PATH = "pims_/api/v1/instances/{instance_id}" + + +def create_get_case_state_tool( + resource: AgentInternalToolResourceConfig, llm: BaseChatModel +) -> StructuredTool: + """Create the GetCaseState internal tool. + + Returns the case instance state from the PIMs ``/v1/instances/{id}`` + endpoint. Shared HTTP/auth/folder/error logic lives in + :func:`build_pims_read_tool`. + """ + return build_pims_read_tool( + resource, + path_template=PIMS_INSTANCE_PATH, + subject="case state", + ) diff --git a/src/uipath_langchain/agent/tools/internal_tools/get_execution_trace_tool.py b/src/uipath_langchain/agent/tools/internal_tools/get_execution_trace_tool.py new file mode 100644 index 000000000..22fa27c5a --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/get_execution_trace_tool.py @@ -0,0 +1,27 @@ +"""Internal tool that fetches the execution trace (audit trail) for a PIMs case.""" + +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import StructuredTool +from uipath.agent.models.agent import AgentInternalToolResourceConfig + +from ._pims_read_tool_factory import build_pims_read_tool + +PIMS_EXECUTION_TRACE_PATH = ( + "pims_/api/v2/element-executions/case-instances/{instance_id}" +) + + +def create_get_execution_trace_tool( + resource: AgentInternalToolResourceConfig, llm: BaseChatModel +) -> StructuredTool: + """Create the GetExecutionTrace internal tool. + + Returns the audit trail (``elementExecutions[]``, ``traceId``, etc.) from + the PIMs ``v2/element-executions/case-instances`` endpoint. Shared + HTTP/auth/folder/error logic lives in :func:`build_pims_read_tool`. + """ + return build_pims_read_tool( + resource, + path_template=PIMS_EXECUTION_TRACE_PATH, + subject="execution trace", + ) diff --git a/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py index 5a8410675..9b0a905a1 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py +++ b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py @@ -29,6 +29,10 @@ 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_entity_tool import create_get_case_entity_tool +from .get_case_plan_tool import create_get_case_plan_tool +from .get_case_state_tool import create_get_case_state_tool +from .get_execution_trace_tool import create_get_execution_trace_tool _INTERNAL_TOOL_HANDLERS: dict[ AgentInternalToolType, @@ -37,6 +41,10 @@ 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, + AgentInternalToolType.GET_CASE_PLAN: create_get_case_plan_tool, + AgentInternalToolType.GET_CASE_ENTITY: create_get_case_entity_tool, + AgentInternalToolType.GET_EXECUTION_TRACE: create_get_execution_trace_tool, } diff --git a/tests/agent/tools/internal_tools/test_get_case_entity_tool.py b/tests/agent/tools/internal_tools/test_get_case_entity_tool.py new file mode 100644 index 000000000..fc8020ff4 --- /dev/null +++ b/tests/agent/tools/internal_tools/test_get_case_entity_tool.py @@ -0,0 +1,150 @@ +"""Tests for get_case_entity_tool.py module.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from uipath.agent.models.agent import ( + AgentInternalGetCaseEntityToolProperties, + AgentInternalToolResourceConfig, + AgentInternalToolType, +) + +from uipath_langchain.agent.exceptions import AgentRuntimeError +from uipath_langchain.agent.tools.internal_tools.get_case_entity_tool import ( + PIMS_CASE_ENTITY_PATH, + create_get_case_entity_tool, +) + + +class TestCreateGetCaseEntityTool: + """Test cases for create_get_case_entity_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": {"entity": {"type": "object"}}, + } + properties = AgentInternalGetCaseEntityToolProperties( + tool_type=AgentInternalToolType.GET_CASE_ENTITY + ) + return AgentInternalToolResourceConfig( + name="get_case_entity", + description="Fetch the case entity (business object, documents, comments) 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._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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, {"caseSummary": "test"} + ) + + tool = create_get_case_entity_tool(resource_config, mock_llm) + assert tool.coroutine is not None + result = await tool.coroutine(instanceId="abc-123", folderKey="folder-xyz") + + assert result == {"caseSummary": "test"} + mock_uipath.api_client.request_async.assert_awaited_once() + args, kwargs = mock_uipath.api_client.request_async.call_args + assert args == ("GET", PIMS_CASE_ENTITY_PATH.format(instance_id="abc-123")) + assert kwargs == {"headers": {"x-uipath-folderkey": "folder-xyz"}} + + @patch( + "uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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, {"caseSummary": "fallback"} + ) + + tool = create_get_case_entity_tool(resource_config, mock_llm) + assert tool.coroutine is not None + result = await tool.coroutine(instanceId="abc-123") + + assert result == {"caseSummary": "fallback"} + args, kwargs = mock_uipath.api_client.request_async.call_args + assert args == ("GET", PIMS_CASE_ENTITY_PATH.format(instance_id="abc-123")) + assert kwargs == {"include_folder_headers": True} + + @patch( + "uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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_entity_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._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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="case not found") + ) + mock_uipath_class.return_value = mock_uipath + + tool = create_get_case_entity_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_ENTITY in _INTERNAL_TOOL_HANDLERS + assert ( + _INTERNAL_TOOL_HANDLERS[AgentInternalToolType.GET_CASE_ENTITY] + is create_get_case_entity_tool + ) diff --git a/tests/agent/tools/internal_tools/test_get_case_plan_tool.py b/tests/agent/tools/internal_tools/test_get_case_plan_tool.py new file mode 100644 index 000000000..91b73dab7 --- /dev/null +++ b/tests/agent/tools/internal_tools/test_get_case_plan_tool.py @@ -0,0 +1,158 @@ +"""Tests for get_case_plan_tool.py module.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from uipath.agent.models.agent import ( + AgentInternalGetCasePlanToolProperties, + AgentInternalToolResourceConfig, + AgentInternalToolType, +) + +from uipath_langchain.agent.exceptions import AgentRuntimeError +from uipath_langchain.agent.tools.internal_tools.get_case_plan_tool import ( + PIMS_CASE_PLAN_PATH, + create_get_case_plan_tool, +) + + +class TestCreateGetCasePlanTool: + """Test cases for create_get_case_plan_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": { + "case": {"type": "object"}, + "stages": {"type": "array"}, + }, + } + properties = AgentInternalGetCasePlanToolProperties( + tool_type=AgentInternalToolType.GET_CASE_PLAN + ) + return AgentInternalToolResourceConfig( + name="get_case_plan", + description="Fetch the case plan (stages, tasks, rules) 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._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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, + {"case": {"exitConditions": []}, "stages": []}, + ) + + tool = create_get_case_plan_tool(resource_config, mock_llm) + assert tool.coroutine is not None + result = await tool.coroutine(instanceId="abc-123", folderKey="folder-xyz") + + assert result == {"case": {"exitConditions": []}, "stages": []} + mock_uipath.api_client.request_async.assert_awaited_once() + args, kwargs = mock_uipath.api_client.request_async.call_args + assert args == ("GET", PIMS_CASE_PLAN_PATH.format(instance_id="abc-123")) + assert kwargs == {"headers": {"x-uipath-folderkey": "folder-xyz"}} + + @patch( + "uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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, + {"case": {"exitConditions": []}, "stages": [{"name": "stage-1"}]}, + ) + + tool = create_get_case_plan_tool(resource_config, mock_llm) + assert tool.coroutine is not None + result = await tool.coroutine(instanceId="abc-123") + + assert result == { + "case": {"exitConditions": []}, + "stages": [{"name": "stage-1"}], + } + args, kwargs = mock_uipath.api_client.request_async.call_args + assert args == ("GET", PIMS_CASE_PLAN_PATH.format(instance_id="abc-123")) + assert kwargs == {"include_folder_headers": True} + + @patch( + "uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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_plan_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._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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="case not found") + ) + mock_uipath_class.return_value = mock_uipath + + tool = create_get_case_plan_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_PLAN in _INTERNAL_TOOL_HANDLERS + assert ( + _INTERNAL_TOOL_HANDLERS[AgentInternalToolType.GET_CASE_PLAN] + is create_get_case_plan_tool + ) diff --git a/tests/agent/tools/internal_tools/test_get_case_state_tool.py b/tests/agent/tools/internal_tools/test_get_case_state_tool.py new file mode 100644 index 000000000..f984b2210 --- /dev/null +++ b/tests/agent/tools/internal_tools/test_get_case_state_tool.py @@ -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._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch("uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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 + ) diff --git a/tests/agent/tools/internal_tools/test_get_execution_trace_tool.py b/tests/agent/tools/internal_tools/test_get_execution_trace_tool.py new file mode 100644 index 000000000..ef40177bd --- /dev/null +++ b/tests/agent/tools/internal_tools/test_get_execution_trace_tool.py @@ -0,0 +1,166 @@ +"""Tests for get_execution_trace_tool.py module.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from uipath.agent.models.agent import ( + AgentInternalGetExecutionTraceToolProperties, + AgentInternalToolResourceConfig, + AgentInternalToolType, +) + +from uipath_langchain.agent.exceptions import AgentRuntimeError +from uipath_langchain.agent.tools.internal_tools.get_execution_trace_tool import ( + PIMS_EXECUTION_TRACE_PATH, + create_get_execution_trace_tool, +) + + +class TestCreateGetExecutionTraceTool: + """Test cases for create_get_execution_trace_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": { + "elementExecutions": {"type": "array"}, + "traceId": {"type": "string"}, + }, + } + properties = AgentInternalGetExecutionTraceToolProperties( + tool_type=AgentInternalToolType.GET_EXECUTION_TRACE + ) + return AgentInternalToolResourceConfig( + name="get_execution_trace", + description="Fetch the audit trail (element executions) of a case instance by 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._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch( + "uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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, + {"elementExecutions": [], "traceId": "trace-1"}, + ) + + tool = create_get_execution_trace_tool(resource_config, mock_llm) + assert tool.coroutine is not None + result = await tool.coroutine(instanceId="abc-123", folderKey="folder-xyz") + + assert result == {"elementExecutions": [], "traceId": "trace-1"} + mock_uipath.api_client.request_async.assert_awaited_once() + args, kwargs = mock_uipath.api_client.request_async.call_args + assert args == ("GET", PIMS_EXECUTION_TRACE_PATH.format(instance_id="abc-123")) + assert kwargs == {"headers": {"x-uipath-folderkey": "folder-xyz"}} + + @patch( + "uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch( + "uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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, + {"elementExecutions": [{"elementId": "e1"}], "traceId": "trace-2"}, + ) + + tool = create_get_execution_trace_tool(resource_config, mock_llm) + assert tool.coroutine is not None + result = await tool.coroutine(instanceId="abc-123") + + assert result == { + "elementExecutions": [{"elementId": "e1"}], + "traceId": "trace-2", + } + args, kwargs = mock_uipath.api_client.request_async.call_args + assert args == ("GET", PIMS_EXECUTION_TRACE_PATH.format(instance_id="abc-123")) + assert kwargs == {"include_folder_headers": True} + + @patch( + "uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch( + "uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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_execution_trace_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._pims_read_tool_factory.mockable", + lambda **kwargs: lambda f: f, + ) + @patch( + "uipath_langchain.agent.tools.internal_tools._pims_read_tool_factory.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="case not found") + ) + mock_uipath_class.return_value = mock_uipath + + tool = create_get_execution_trace_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_EXECUTION_TRACE in _INTERNAL_TOOL_HANDLERS + assert ( + _INTERNAL_TOOL_HANDLERS[AgentInternalToolType.GET_EXECUTION_TRACE] + is create_get_execution_trace_tool + )