From 6e11d2281a7e605f4e3f26d8ab4bcddd775011d6 Mon Sep 17 00:00:00 2001 From: dianapirvulescu Date: Wed, 11 Mar 2026 12:33:50 +0200 Subject: [PATCH 01/33] feat: add execution stage for new guardrails (#683) --- pyproject.toml | 2 +- .../agent/react/guardrails/guardrails_subgraph.py | 2 ++ uv.lock | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1821ff8f2..c6549966d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.14" +version = "0.8.15" 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/react/guardrails/guardrails_subgraph.py b/src/uipath_langchain/agent/react/guardrails/guardrails_subgraph.py index 38b366a80..1d56547ba 100644 --- a/src/uipath_langchain/agent/react/guardrails/guardrails_subgraph.py +++ b/src/uipath_langchain/agent/react/guardrails/guardrails_subgraph.py @@ -33,6 +33,8 @@ _VALIDATOR_ALLOWED_STAGES = { "prompt_injection": {ExecutionStage.PRE_EXECUTION}, "pii_detection": {ExecutionStage.PRE_EXECUTION, ExecutionStage.POST_EXECUTION}, + "harmful_content": {ExecutionStage.PRE_EXECUTION, ExecutionStage.POST_EXECUTION}, + "intellectual_property": {ExecutionStage.POST_EXECUTION}, } diff --git a/uv.lock b/uv.lock index ecfeeeebb..9cbaae30c 100644 --- a/uv.lock +++ b/uv.lock @@ -3324,7 +3324,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.14" +version = "0.8.15" source = { editable = "." } dependencies = [ { name = "httpx" }, From fec76240bdb922e9e6fc496af0496103d07f9dd7 Mon Sep 17 00:00:00 2001 From: cristian-groza <90202267+cristian-groza@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:38:27 +0200 Subject: [PATCH 02/33] fix: analyze files tool when mime type is missing (#684) --- pyproject.toml | 2 +- .../internal_tools/analyze_files_tool.py | 10 +- .../internal_tools/test_analyze_files_tool.py | 125 +++++++++++++++++- uv.lock | 2 +- 4 files changed, 133 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c6549966d..d792d5d81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.15" +version = "0.8.16" 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/internal_tools/analyze_files_tool.py b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py index 6010311f5..22a821d6d 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py @@ -1,3 +1,4 @@ +import mimetypes import uuid from typing import Any, cast @@ -154,12 +155,17 @@ async def _resolve_job_attachment_arguments( continue attachment_id = uuid.UUID(attachment_id_value) - mime_type = getattr(attachment, "MimeType", "") - blob_info = await client.attachments.get_blob_file_access_uri_async( key=attachment_id ) + input_mime_type = getattr(attachment, "MimeType", None) + mime_type = ( + input_mime_type + if input_mime_type + else (mimetypes.guess_type(blob_info.name)[0] or "") + ) + file_info = FileInfo( url=blob_info.uri, name=blob_info.name, diff --git a/tests/agent/tools/internal_tools/test_analyze_files_tool.py b/tests/agent/tools/internal_tools/test_analyze_files_tool.py index 311696cc6..de4d4c97c 100644 --- a/tests/agent/tools/internal_tools/test_analyze_files_tool.py +++ b/tests/agent/tools/internal_tools/test_analyze_files_tool.py @@ -380,8 +380,10 @@ async def test_resolve_empty_attachments_list(self, mock_uipath_client): assert len(result) == 0 - async def test_resolve_attachment_with_missing_mime_type(self, mock_uipath_client): - """Test resolving attachment with missing MimeType defaults to empty string.""" + async def test_resolve_attachment_with_missing_mime_type_guesses_from_filename( + self, mock_uipath_client + ): + """Test resolving attachment with missing MimeType guesses from blob name.""" attachment_id = uuid.uuid4() class AttachmentWithoutMimeType(BaseModel): @@ -404,9 +406,128 @@ class AttachmentWithoutMimeType(BaseModel): result = await _resolve_job_attachment_arguments([mock_attachment]) + assert len(result) == 1 + assert result[0].mime_type == "application/pdf" + + async def test_resolve_attachment_with_none_mime_type_guesses_from_filename( + self, mock_uipath_client + ): + """Test that a None MimeType attribute is handled by guessing from filename.""" + attachment_id = uuid.uuid4() + + class AttachmentWithNoneMimeType(BaseModel): + model_config = ConfigDict(populate_by_name=True) + ID: str = Field(alias="ID") + FullName: str = Field(alias="FullName") + MimeType: str | None = Field(alias="MimeType", default=None) + + mock_attachment = AttachmentWithNoneMimeType( + ID=str(attachment_id), + FullName="report.png", + MimeType=None, + ) + + mock_blob_info = MockBlobInfo( + uri="https://blob.storage.com/files/report.png", + name="report.png", + ) + + mock_uipath_client.attachments.get_blob_file_access_uri_async = AsyncMock( + return_value=mock_blob_info + ) + + result = await _resolve_job_attachment_arguments([mock_attachment]) + + assert len(result) == 1 + assert result[0].mime_type == "image/png" + + async def test_resolve_attachment_with_empty_mime_type_guesses_from_filename( + self, mock_uipath_client + ): + """Test that an empty string MimeType is handled by guessing from filename.""" + attachment_id = uuid.uuid4() + + class AttachmentWithEmptyMimeType(BaseModel): + model_config = ConfigDict(populate_by_name=True) + ID: str = Field(alias="ID") + FullName: str = Field(alias="FullName") + MimeType: str = Field(alias="MimeType") + + mock_attachment = AttachmentWithEmptyMimeType( + ID=str(attachment_id), + FullName="image.jpg", + MimeType="", + ) + + mock_blob_info = MockBlobInfo( + uri="https://blob.storage.com/files/image.jpg", + name="image.jpg", + ) + + mock_uipath_client.attachments.get_blob_file_access_uri_async = AsyncMock( + return_value=mock_blob_info + ) + + result = await _resolve_job_attachment_arguments([mock_attachment]) + + assert len(result) == 1 + assert result[0].mime_type == "image/jpeg" + + async def test_resolve_attachment_with_no_mime_type_and_unknown_extension( + self, mock_uipath_client + ): + """Test that unguessable MIME type falls back to empty string.""" + attachment_id = uuid.uuid4() + + class AttachmentWithoutMimeType(BaseModel): + ID: str + FullName: str + + mock_attachment = AttachmentWithoutMimeType( + ID=str(attachment_id), + FullName="data.xyz123", + ) + + mock_blob_info = MockBlobInfo( + uri="https://blob.storage.com/files/data.xyz123", + name="data.xyz123", + ) + + mock_uipath_client.attachments.get_blob_file_access_uri_async = AsyncMock( + return_value=mock_blob_info + ) + + result = await _resolve_job_attachment_arguments([mock_attachment]) + assert len(result) == 1 assert result[0].mime_type == "" + async def test_resolve_attachment_with_valid_mime_type_uses_it( + self, mock_uipath_client + ): + """Test that a valid MimeType from the attachment is used as-is.""" + attachment_id = uuid.uuid4() + + mock_attachment = MockAttachment( + ID=str(attachment_id), + FullName="document.pdf", + MimeType="application/pdf", + ) + + mock_blob_info = MockBlobInfo( + uri="https://blob.storage.com/files/document.pdf", + name="document.pdf", + ) + + mock_uipath_client.attachments.get_blob_file_access_uri_async = AsyncMock( + return_value=mock_blob_info + ) + + result = await _resolve_job_attachment_arguments([mock_attachment]) + + assert len(result) == 1 + assert result[0].mime_type == "application/pdf" + async def test_resolve_attachment_with_invalid_uuid_raises( self, mock_uipath_client ): diff --git a/uv.lock b/uv.lock index 9cbaae30c..2d63bf6a1 100644 --- a/uv.lock +++ b/uv.lock @@ -3324,7 +3324,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.15" +version = "0.8.16" source = { editable = "." } dependencies = [ { name = "httpx" }, From 2947c1eebcd14b947aca44a251088f4934a87488 Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:06:38 -0700 Subject: [PATCH 03/33] fix: durable interrupt tools for cas (#677) --- pyproject.toml | 2 +- src/uipath_langchain/agent/tools/tool_node.py | 11 +++++++++++ uv.lock | 14 +++++++------- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d792d5d81..2b5f026ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.16" +version = "0.8.17" 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/tool_node.py b/src/uipath_langchain/agent/tools/tool_node.py index 6d6b9fc5b..eb187ebab 100644 --- a/src/uipath_langchain/agent/tools/tool_node.py +++ b/src/uipath_langchain/agent/tools/tool_node.py @@ -7,6 +7,7 @@ from langchain_core.messages.tool import ToolCall, ToolMessage from langchain_core.tools import BaseTool from langgraph._internal._runnable import RunnableCallable +from langgraph.errors import GraphBubbleUp from langgraph.types import Command from pydantic import BaseModel from uipath.platform.resume_triggers import is_no_content_marker @@ -88,6 +89,11 @@ def _func(self, state: AgentGraphState) -> OutputType: else: result = self.tool.invoke(call) return self._process_result(call, result) + except GraphBubbleUp: + # LangGraph uses exceptions for interrupt control flow — re-raise so + # handle_tool_errors doesn't swallow expected interrupts as errors. + # https://langchain-ai.github.io/langgraph/concepts/human_in_the_loop/ + raise except Exception as e: if self.handle_tool_errors: return self._process_error_result(call, e) @@ -107,6 +113,11 @@ async def _afunc(self, state: AgentGraphState) -> OutputType: else: result = await self.tool.ainvoke(call) return self._process_result(call, result) + except GraphBubbleUp: + # LangGraph uses exceptions for interrupt control flow — re-raise so + # handle_tool_errors doesn't swallow expected interrupts as errors. + # https://langchain-ai.github.io/langgraph/concepts/human_in_the_loop/ + raise except Exception as e: if self.handle_tool_errors: return self._process_error_result(call, e) diff --git a/uv.lock b/uv.lock index 2d63bf6a1..3dc31ee3b 100644 --- a/uv.lock +++ b/uv.lock @@ -3280,7 +3280,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.7" +version = "2.10.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -3303,9 +3303,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/ae/d63b4a9210b10d165c8867b9dda4195ddb3729063cc5ae4f514d9a4a186e/uipath-2.10.7.tar.gz", hash = "sha256:34115b39e52049b3814163701f5294492ae14821241f742d6f36c7313baa1684", size = 2454157, upload-time = "2026-03-05T12:25:15.547Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/afaab06bdec6383eb7af76cc6138dfe1ea07a29d887556e8e10ff303cb13/uipath-2.10.10.tar.gz", hash = "sha256:542799a16bfeb7c8024e70cb9fed2466ccdcf2d597edc19e9d28bfd91adde0a3", size = 2455323, upload-time = "2026-03-06T22:14:51.499Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/aa/c13b14a085f8793e2157a81c4249390de1c56014c38ca45e6cb593e80fc3/uipath-2.10.7-py3-none-any.whl", hash = "sha256:c960a318a432e6d23dedbc2463c441832ac670169145fcffc5ad24ad8d85b851", size = 356892, upload-time = "2026-03-05T12:25:13.318Z" }, + { url = "https://files.pythonhosted.org/packages/be/f9/ab060e44b4bc784e93a1c33ad3f1eb4c19e55b3b824c4021457e881a28c3/uipath-2.10.10-py3-none-any.whl", hash = "sha256:2935fc7902fa1295774b42f7512ab278c7ae6a18cb02a99fb0167f9cb1fda880", size = 357294, upload-time = "2026-03-06T22:14:49.511Z" }, ] [[package]] @@ -3324,7 +3324,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.16" +version = "0.8.17" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -3432,14 +3432,14 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.9.1" +version = "0.9.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/f5/4c3fd329f51a36b5aaf8613a2252b1c54e552e6a6928f58a9d49a71c8a08/uipath_runtime-0.9.1.tar.gz", hash = "sha256:a26e1b3767b7370d729c7149c1f5eddbcd4663ad61da3d968974837a70154c32", size = 137936, upload-time = "2026-02-23T11:57:31.568Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/eb/774d503e174e17af84eabb9d315683c5616fb0da956f93f99cf79912a6ff/uipath_runtime-0.9.3.tar.gz", hash = "sha256:5010ede13919d6883a6373cd137c06c18de2b6e2e9fa6b65e11f2d255db444e5", size = 139338, upload-time = "2026-03-04T20:49:11.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/aa/f1a5697cde66e1ecd96e8648bc4f2e7423633ff64371cf09f9fe38652d8f/uipath_runtime-0.9.1-py3-none-any.whl", hash = "sha256:1560c7d9092cec132b68435c8ce7a6e3ec02c4e62fbcacdcf0943341bcc9250a", size = 41697, upload-time = "2026-02-23T11:57:29.026Z" }, + { url = "https://files.pythonhosted.org/packages/80/83/52dc4d5bf534368d753656881306dffeed658e5e7eefcb91fe6c3bd0dfac/uipath_runtime-0.9.3-py3-none-any.whl", hash = "sha256:20aefc8d9b23978032bec67d2057b5457059aaf1ea788a71d218ba8b6816ff98", size = 41766, upload-time = "2026-03-04T20:49:10.005Z" }, ] [[package]] From fc2a17ec896dbf11ee726c517901d0fd2039429e Mon Sep 17 00:00:00 2001 From: Cristian Cotovanu <87022468+cotovanu-cristian@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:20:51 +0200 Subject: [PATCH 04/33] fix: use centralized header merging in httpx clients (#685) Co-authored-by: Claude Opus 4.6 --- pyproject.toml | 2 +- .../agent/tools/context_tool.py | 7 ++++++ .../agent/tools/mcp/mcp_client.py | 8 ++----- src/uipath_langchain/chat/vertex.py | 7 +++--- uv.lock | 24 +++++++++++++------ 5 files changed, 31 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2b5f026ed..92daeb445 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.17" +version = "0.8.18" 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/context_tool.py b/src/uipath_langchain/agent/tools/context_tool.py index 472bafe42..84f61d457 100644 --- a/src/uipath_langchain/agent/tools/context_tool.py +++ b/src/uipath_langchain/agent/tools/context_tool.py @@ -70,6 +70,7 @@ def _build_folder_path_prefix_arg_props( at the resource level but does set settings.folder_path_prefix with variant="argument". """ + assert resource.settings is not None assert resource.settings.folder_path_prefix is not None argument_path = (resource.settings.folder_path_prefix.value or "").strip("{}") return { @@ -83,12 +84,14 @@ def _build_folder_path_prefix_arg_props( def is_static_query(resource: AgentContextResourceConfig) -> bool: """Check if the resource configuration uses a static query variant.""" + assert resource.settings is not None if resource.settings.query is None or resource.settings.query.variant is None: return False return resource.settings.query.variant.lower() == "static" def create_context_tool(resource: AgentContextResourceConfig) -> StructuredTool: + assert resource.settings is not None tool_name = sanitize_tool_name(resource.name) retrieval_mode = resource.settings.retrieval_mode.lower() if retrieval_mode == AgentContextRetrievalMode.DEEP_RAG.value.lower(): @@ -102,6 +105,7 @@ def create_context_tool(resource: AgentContextResourceConfig) -> StructuredTool: def handle_semantic_search( tool_name: str, resource: AgentContextResourceConfig ) -> StructuredTool: + assert resource.settings is not None ensure_valid_fields(resource) assert resource.settings.query.variant is not None @@ -172,6 +176,7 @@ async def context_tool_fn(query: Optional[str] = None) -> dict[str, Any]: def handle_deep_rag( tool_name: str, resource: AgentContextResourceConfig ) -> StructuredTool: + assert resource.settings is not None ensure_valid_fields(resource) assert resource.settings.query.variant is not None @@ -291,6 +296,7 @@ async def create_deep_rag(): def handle_batch_transform( tool_name: str, resource: AgentContextResourceConfig ) -> StructuredTool: + assert resource.settings is not None ensure_valid_fields(resource) assert resource.settings.query is not None @@ -458,6 +464,7 @@ async def context_batch_transform_wrapper( def ensure_valid_fields(resource_config: AgentContextResourceConfig): + assert resource_config.settings is not None if not resource_config.settings.query.variant: raise AgentStartupError( code=AgentStartupErrorCode.INVALID_TOOL_CONFIG, diff --git a/src/uipath_langchain/agent/tools/mcp/mcp_client.py b/src/uipath_langchain/agent/tools/mcp/mcp_client.py index 4bf3cc9b3..c201a2dcb 100644 --- a/src/uipath_langchain/agent/tools/mcp/mcp_client.py +++ b/src/uipath_langchain/agent/tools/mcp/mcp_client.py @@ -167,12 +167,8 @@ async def _initialize_client(self) -> None: await self._stack.__aenter__() # Create HTTP client with SSL, proxy, and redirect settings - default_client_kwargs = get_httpx_client_kwargs() - client_kwargs = { - **default_client_kwargs, - "headers": self._headers, - "timeout": self._timeout, - } + client_kwargs = get_httpx_client_kwargs(headers=self._headers) + client_kwargs["timeout"] = self._timeout self._http_client = await self._stack.enter_async_context( httpx.AsyncClient(**client_kwargs) ) diff --git a/src/uipath_langchain/chat/vertex.py b/src/uipath_langchain/chat/vertex.py index 75e44b3c3..a8a577aff 100644 --- a/src/uipath_langchain/chat/vertex.py +++ b/src/uipath_langchain/chat/vertex.py @@ -193,23 +193,24 @@ def __init__( ) header_capture = HeaderCapture(name=f"vertex_headers_{id(self)}") - client_kwargs = get_httpx_client_kwargs() + client_kwargs = get_httpx_client_kwargs(headers=headers) client_kwargs["timeout"] = 300.0 verify = client_kwargs.get("verify", True) + merged_headers = client_kwargs.pop("headers", {}) http_options = genai_types.HttpOptions( httpx_client=httpx.Client( transport=_UrlRewriteTransport( uipath_url, verify=verify, header_capture=header_capture ), - headers=headers, + headers=merged_headers, **client_kwargs, ), httpx_async_client=httpx.AsyncClient( transport=_AsyncUrlRewriteTransport( uipath_url, verify=verify, header_capture=header_capture ), - headers=headers, + headers=merged_headers, **client_kwargs, ), ) diff --git a/uv.lock b/uv.lock index 3dc31ee3b..b43893257 100644 --- a/uv.lock +++ b/uv.lock @@ -3075,6 +3075,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/98/e8bc58b178266eae2fcf4c9c7a8303a8d41164d781b32d71097924a6bebe/sqlite_vec-0.1.6-py3-none-win_amd64.whl", hash = "sha256:c65bcfd90fa2f41f9000052bcb8bb75d38240b2dae49225389eca6c3136d3f0c", size = 281540, upload-time = "2024-11-20T16:40:37.296Z" }, ] +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + [[package]] name = "sse-starlette" version = "3.2.0" @@ -3280,7 +3289,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.10" +version = "2.10.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -3303,9 +3312,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/55/afaab06bdec6383eb7af76cc6138dfe1ea07a29d887556e8e10ff303cb13/uipath-2.10.10.tar.gz", hash = "sha256:542799a16bfeb7c8024e70cb9fed2466ccdcf2d597edc19e9d28bfd91adde0a3", size = 2455323, upload-time = "2026-03-06T22:14:51.499Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/3a/3a93f5c54078b993e6cecdae6069ebafe122fceb639b1f59ad0aa3f1b765/uipath-2.10.13.tar.gz", hash = "sha256:13795c00dfb7391f248efb6ae4b96f096a4a8131d0df5f8ec0b7f265be1d0e10", size = 2456921, upload-time = "2026-03-13T07:51:16.484Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/f9/ab060e44b4bc784e93a1c33ad3f1eb4c19e55b3b824c4021457e881a28c3/uipath-2.10.10-py3-none-any.whl", hash = "sha256:2935fc7902fa1295774b42f7512ab278c7ae6a18cb02a99fb0167f9cb1fda880", size = 357294, upload-time = "2026-03-06T22:14:49.511Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f9/c8745f866a39047f529ee0413aedd8fbf81f7ffd5dd46862cb5e6221d58d/uipath-2.10.13-py3-none-any.whl", hash = "sha256:fc1c8503b9cc3538cf3003cb10e1e3e736529aba4272086066402fde21154b65", size = 357769, upload-time = "2026-03-13T07:51:14.599Z" }, ] [[package]] @@ -3324,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.17" +version = "0.8.18" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -3416,18 +3425,19 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.0.18" +version = "0.0.23" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic-function-models" }, + { name = "sqlparse" }, { name = "tenacity" }, { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/eb/848292baafcb7273b189004161b754e46431bb28a711b7f359a2b7642b27/uipath_platform-0.0.18.tar.gz", hash = "sha256:0d0cf196ffc06de90c3ec12a7b52d88b81f38a34e361eb81690ff88cd2a9a0bd", size = 264141, upload-time = "2026-03-09T16:24:11.158Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/c1/f1cfd23d977fc2fec2e880dac42b1fdb78c28fef80008ebf4bd43ca9b12f/uipath_platform-0.0.23.tar.gz", hash = "sha256:a84d9da29865155080efcc837f6c2b2186d480e34fc877dec06f3ec315c1e52c", size = 269669, upload-time = "2026-03-13T07:50:06.953Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/69/a96b8c66d4a0ed5d5ac1a3e44a5836d1b9e4151519a884b67d8db18fb3d8/uipath_platform-0.0.18-py3-none-any.whl", hash = "sha256:9f46b0b01254b95b18cc753bd91f5a5802d0a15e7284224d6f9a1309bb71b6bf", size = 159073, upload-time = "2026-03-09T16:24:09.717Z" }, + { url = "https://files.pythonhosted.org/packages/cf/32/167abe3730ab8c0dd89abee6ebea6e1a35e0a1548be620a491a60bc7cc0d/uipath_platform-0.0.23-py3-none-any.whl", hash = "sha256:e2ea4d9341540a5a02baca8e07ba05dfb8947f2d525f8677834f81c735826772", size = 161946, upload-time = "2026-03-13T07:50:04.956Z" }, ] [[package]] From 848f660e782516bd802290132199b93ffcb9bc7f Mon Sep 17 00:00:00 2001 From: Cristian Cotovanu <87022468+cotovanu-cristian@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:37:55 +0200 Subject: [PATCH 05/33] fix: coerce stringified dict/list fields in analyze files tool input args (#686) Co-authored-by: Claude Opus 4.6 --- pyproject.toml | 2 +- .../agent/react/json_utils.py | 58 +++++++ .../agent/wrappers/job_attachment_wrapper.py | 8 +- tests/agent/react/test_json_utils.py | 146 ++++++++++++++++++ uv.lock | 2 +- 5 files changed, 212 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 92daeb445..eb34651ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.18" +version = "0.8.19" 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/react/json_utils.py b/src/uipath_langchain/agent/react/json_utils.py index b7e4bf67d..c4c6e23e8 100644 --- a/src/uipath_langchain/agent/react/json_utils.py +++ b/src/uipath_langchain/agent/react/json_utils.py @@ -1,3 +1,5 @@ +import ast +import json import sys from typing import Any, ForwardRef, Union, get_args, get_origin @@ -212,3 +214,59 @@ def _json_key(field_name: str, field_info: Any) -> str: def _is_pydantic_model(annotation: Any) -> bool: return isinstance(annotation, type) and issubclass(annotation, BaseModel) + + +def _coerce_field(key: str, value: Any, schema: type[BaseModel] | None) -> Any: + """Coerce a single field value, skipping str-typed fields when schema is available.""" + if schema is None: + return coerce_json_strings(value) + + field_info = schema.model_fields.get(key) + if field_info is None: + return coerce_json_strings(value) + + annotation = _unwrap_optional(field_info.annotation) + + if annotation is str: + return value + + if _is_pydantic_model(annotation): + return coerce_json_strings(value, annotation) + + if get_origin(annotation) is list: + item_args = get_args(annotation) + item_schema = None + if item_args and _is_pydantic_model(item_args[0]): + item_schema = item_args[0] + if isinstance(value, list): + return [coerce_json_strings(item, item_schema) for item in value] + + return coerce_json_strings(value) + + +def coerce_json_strings(data: Any, schema: type[BaseModel] | None = None) -> Any: + """Parse stringified dicts/lists back into Python objects. + + LLMs sometimes serialize nested objects as strings instead of dicts, + either as JSON (double quotes) or Python repr (single quotes). + When a schema is provided, str-typed fields are left untouched. + """ + if isinstance(data, dict): + return {k: _coerce_field(k, v, schema) for k, v in data.items()} + if isinstance(data, list): + return [coerce_json_strings(item) for item in data] + if isinstance(data, str): + try: + parsed = json.loads(data) + if isinstance(parsed, (dict, list)): + return parsed + except (json.JSONDecodeError, TypeError): + pass + # LLMs sometimes emit Python repr (single quotes) instead of JSON + try: + parsed = ast.literal_eval(data) + if isinstance(parsed, (dict, list)): + return parsed + except (ValueError, SyntaxError): + pass + return data diff --git a/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py index 0a3332f9e..bbb4f1da1 100644 --- a/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py +++ b/src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py @@ -11,6 +11,7 @@ get_job_attachments, replace_job_attachment_ids, ) +from uipath_langchain.agent.react.json_utils import coerce_json_strings from uipath_langchain.agent.react.types import AgentGraphState from uipath_langchain.agent.tools.tool_node import AsyncToolWrapperWithState @@ -73,18 +74,21 @@ async def job_attachment_wrapper( input_args = call["args"] modified_input_args = input_args + schema = None if isinstance(tool.args_schema, type) and issubclass( tool.args_schema, BaseModel ): + schema = tool.args_schema errors: list[str] = [] - paths = get_job_attachment_paths(tool.args_schema) + paths = get_job_attachment_paths(schema) modified_input_args = replace_job_attachment_ids( paths, input_args, state.inner_state.job_attachments, errors ) if errors: return {"error": "\n".join(errors)} - call["args"] = modified_input_args + + call["args"] = coerce_json_strings(modified_input_args, schema) tool_result = await tool.ainvoke(call) job_attachments_dict = {} if output_type is not None: diff --git a/tests/agent/react/test_json_utils.py b/tests/agent/react/test_json_utils.py index 32a890f86..43c1017a8 100644 --- a/tests/agent/react/test_json_utils.py +++ b/tests/agent/react/test_json_utils.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, RootModel from uipath_langchain.agent.react.json_utils import ( + coerce_json_strings, extract_values_by_paths, get_json_paths_by_type, ) @@ -323,3 +324,148 @@ def test_underscore_field_list_jsonpath(self) -> None: model = create_model(schema) paths = get_json_paths_by_type(model, "__Job_attachment") assert paths == ["$._files[*]"] + + +# -- coerce_json_strings: no schema (blind coercion) -------------------------- + + +class TestCoerceJsonStringsNoSchema: + """Without a schema, all parseable strings are coerced.""" + + def test_no_coercion_needed(self) -> None: + data = {"name": "test", "count": 42} + assert coerce_json_strings(data) == data + + def test_json_object_string(self) -> None: + data = {"metadata": '{"size": "99353"}'} + assert coerce_json_strings(data) == {"metadata": {"size": "99353"}} + + def test_python_repr_string(self) -> None: + data = {"metadata": "{'size': '99353'}"} + assert coerce_json_strings(data) == {"metadata": {"size": "99353"}} + + def test_nested_in_dict(self) -> None: + data = {"attachment": {"metadata": '{"size": 1024}', "name": "file.pdf"}} + assert coerce_json_strings(data) == { + "attachment": {"metadata": {"size": 1024}, "name": "file.pdf"} + } + + def test_in_list_items(self) -> None: + data = { + "items": [ + {"metadata": '{"size": 100}', "name": "a.pdf"}, + {"metadata": {"size": 200}, "name": "b.pdf"}, + ] + } + assert coerce_json_strings(data) == { + "items": [ + {"metadata": {"size": 100}, "name": "a.pdf"}, + {"metadata": {"size": 200}, "name": "b.pdf"}, + ] + } + + def test_invalid_string_unchanged(self) -> None: + data = {"metadata": "not valid json"} + assert coerce_json_strings(data) == data + + def test_json_array_string(self) -> None: + data = {"tags": "[1, 2, 3]"} + assert coerce_json_strings(data) == {"tags": [1, 2, 3]} + + def test_plain_string_unchanged(self) -> None: + data = {"name": "hello world"} + assert coerce_json_strings(data) == data + + def test_empty_dict(self) -> None: + assert coerce_json_strings({}) == {} + + def test_dict_value_unchanged(self) -> None: + data = {"metadata": {"already": "a dict"}} + assert coerce_json_strings(data) == data + + def test_json_primitives_unchanged(self) -> None: + """JSON primitives (numbers, booleans) stay as strings.""" + data = {"value": "42", "flag": "true"} + assert coerce_json_strings(data) == data + + def test_non_dict_passthrough(self) -> None: + assert coerce_json_strings(42) == 42 + assert coerce_json_strings(None) is None + assert coerce_json_strings(True) is True + + +# -- coerce_json_strings: with schema ----------------------------------------- + + +class TestCoerceJsonStringsWithSchema: + """With a schema, str-typed fields are protected from coercion.""" + + def test_str_field_preserved_dict_field_coerced(self) -> None: + """The real-world Analyze_Files scenario.""" + + class AttachmentInput(BaseModel): + ID: str + FullName: str + MimeType: str + Metadata: dict[str, Any] | None = None + + class AnalyzeFilesInput(BaseModel): + analysisTask: str + attachments: list[AttachmentInput] + + data = { + "analysisTask": '{"instruction": "summarize the document"}', + "attachments": [ + { + "ID": "550e8400-e29b-41d4-a716-446655440000", + "FullName": "report.pdf", + "MimeType": "application/pdf", + "Metadata": '{"size": "99353"}', + } + ], + } + result = coerce_json_strings(data, AnalyzeFilesInput) + + assert result["attachments"][0]["Metadata"] == {"size": "99353"} + assert isinstance(result["analysisTask"], str) + assert result["analysisTask"] == '{"instruction": "summarize the document"}' + + def test_python_repr_with_schema(self) -> None: + """Single-quoted Python repr is coerced for dict fields.""" + + class Inner(BaseModel): + Metadata: dict[str, Any] | None = None + + class Outer(BaseModel): + item: Inner + + data = {"item": {"Metadata": "{'size': '99353'}"}} + result = coerce_json_strings(data, Outer) + assert result["item"]["Metadata"] == {"size": "99353"} + + def test_unknown_field_coerced(self) -> None: + """Fields not in the schema fall back to blind coercion.""" + + class Schema(BaseModel): + name: str + + data = {"name": "test", "extra": '{"a": 1}'} + result = coerce_json_strings(data, Schema) + assert result["name"] == "test" + assert result["extra"] == {"a": 1} + + def test_nested_model_field_recurses(self) -> None: + """BaseModel-typed fields recurse with child schema.""" + + class Child(BaseModel): + value: str + data: dict[str, Any] | None = None + + class Parent(BaseModel): + child: Child + + result = coerce_json_strings( + {"child": {"value": '{"x": 1}', "data": '{"y": 2}'}}, Parent + ) + assert result["child"]["value"] == '{"x": 1}' + assert result["child"]["data"] == {"y": 2} diff --git a/uv.lock b/uv.lock index b43893257..25fc26166 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.18" +version = "0.8.19" source = { editable = "." } dependencies = [ { name = "httpx" }, From c85613e56f224048116b2303ad4f0b0ccee415fd Mon Sep 17 00:00:00 2001 From: Valentina Bojan Date: Fri, 13 Mar 2026 17:33:28 +0200 Subject: [PATCH 06/33] fix: preserve AIMessage id in replace_tool_calls (#688) Co-authored-by: Valentina Bojan Co-authored-by: Claude Opus 4.6 --- pyproject.toml | 2 +- .../agent/messages/message_utils.py | 1 + tests/agent/messages/test_message_utils.py | 76 ++++++++++++++++++- uv.lock | 2 +- 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eb34651ef..1342465e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.19" +version = "0.8.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/messages/message_utils.py b/src/uipath_langchain/agent/messages/message_utils.py index 20e2e679a..c56d521f1 100644 --- a/src/uipath_langchain/agent/messages/message_utils.py +++ b/src/uipath_langchain/agent/messages/message_utils.py @@ -37,4 +37,5 @@ def replace_tool_calls(message: AIMessage, tool_calls: list[ToolCall]) -> AIMess content_blocks=content_blocks, tool_calls=tool_calls, response_metadata=response_metadata, + id=message.id, ) diff --git a/tests/agent/messages/test_message_utils.py b/tests/agent/messages/test_message_utils.py index 7c7a5cd76..26ba2331d 100644 --- a/tests/agent/messages/test_message_utils.py +++ b/tests/agent/messages/test_message_utils.py @@ -1,14 +1,20 @@ """Tests for agent/messages/message_utils.py module.""" -from langchain.messages import AIMessage, ToolCall +from typing import Any, Union + +from langchain.messages import AIMessage, HumanMessage, ToolCall +from langchain_core.messages import BaseMessage from langchain_core.messages.content import ( ContentBlock, create_text_block, create_tool_call, ) +from langgraph.graph.message import add_messages from uipath_langchain.agent.messages.message_utils import replace_tool_calls +MessageItem = Union[BaseMessage, list[str], tuple[str, str], str, dict[str, Any]] + class TestReplaceToolCalls: """Test cases for replace_tool_calls function.""" @@ -179,6 +185,74 @@ def test_replace_tool_calls_no_original_metadata(self): assert len(tool_call_blocks) == 1 assert tool_call_blocks[0]["name"] == "new_tool" + def test_replace_tool_calls_preserves_message_id(self): + """Test that the original message id is preserved after replacement.""" + original_tool_calls = [ToolCall(name="old_tool", args={}, id="old_id")] + original_content_blocks: list[ContentBlock] = [ + create_text_block("Test message"), + create_tool_call(name="old_tool", args={}, id="old_id"), + ] + original_message = AIMessage( + content_blocks=original_content_blocks, + tool_calls=original_tool_calls, + id="msg-original-id", + ) + + new_tool_calls = [ToolCall(name="new_tool", args={}, id="new_id")] + + result = replace_tool_calls(original_message, new_tool_calls) + + assert result.id == "msg-original-id" + + def test_replace_tool_calls_updated_args_visible_via_add_messages(self): + """Test that updated tool call args are visible after add_messages processes them. + + Reproduces the HITL bug: when a human reviews and updates activity input + during an escalation, the activity must execute with the reviewed args. + Without id preservation, add_messages appends a duplicate AIMessage + instead of replacing the original, causing the tool to run with stale args. + """ + original_tool_calls = [ + ToolCall(name="my_activity", args={"input": "original_value"}, id="call_1") + ] + original_ai_message = AIMessage( + content_blocks=[ + create_text_block("I will invoke the activity"), + create_tool_call( + name="my_activity", args={"input": "original_value"}, id="call_1" + ), + ], + tool_calls=original_tool_calls, + id="msg-from-llm", + ) + + messages: list[MessageItem] = [ + HumanMessage(content="do something", id="msg-human"), + original_ai_message, + ] + + # Simulate HITL review: human changes the input + reviewed_tool_calls = [ + ToolCall(name="my_activity", args={"input": "reviewed_value"}, id="call_1") + ] + updated_ai_message = replace_tool_calls( + original_ai_message, reviewed_tool_calls + ) + + # Simulate what Command(update={"messages": [updated_ai_message]}) does + new_messages: list[MessageItem] = [updated_ai_message] + result_messages = add_messages(messages, new_messages) + + # There must be exactly one AIMessage — not a duplicate + ai_messages = [m for m in result_messages if isinstance(m, AIMessage)] + assert len(ai_messages) == 1, ( + f"Expected 1 AIMessage but got {len(ai_messages)}; " + "add_messages appended instead of replacing (id mismatch)" + ) + + # The surviving AIMessage must carry the reviewed args + assert ai_messages[0].tool_calls[0]["args"] == {"input": "reviewed_value"} + def test_replace_tool_calls_content_blocks(self): """Test that non-tool content blocks are preserved.""" original_tool_calls = [ToolCall(name="old_tool", args={}, id="old_id")] diff --git a/uv.lock b/uv.lock index 25fc26166..fcff15fdf 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.19" +version = "0.8.20" source = { editable = "." } dependencies = [ { name = "httpx" }, From 84aa589c4c3cfd55758dcf86eb236c37a956204f Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:33:03 -0700 Subject: [PATCH 07/33] fix: derive mime type from referenced file name [JAR-9397] (#689) --- src/uipath_langchain/runtime/_citations.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/uipath_langchain/runtime/_citations.py b/src/uipath_langchain/runtime/_citations.py index 4bf731fbe..37b89c89a 100644 --- a/src/uipath_langchain/runtime/_citations.py +++ b/src/uipath_langchain/runtime/_citations.py @@ -2,6 +2,7 @@ import json import logging +import mimetypes import re from dataclasses import dataclass from typing import Any @@ -108,10 +109,11 @@ def _make_source( if citation not in source_numbers: source_numbers[citation] = next_number next_number += 1 + mime_type, _ = mimetypes.guess_type(citation.title) return UiPathConversationCitationSourceMedia( title=citation.title, number=source_numbers[citation], - mime_type=None, + mime_type=mime_type, download_url=citation.reference, page_number=citation.page_number, ), next_number From 29ba5ebf84f3a19e537f5ff836e16d5be6c76067 Mon Sep 17 00:00:00 2001 From: Gabriel Martin <91462031+gcuip@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:48:08 +0100 Subject: [PATCH 08/33] feat: migrate context tool to unified-search; use wrappers in context_tool [ECS-1658 ECS-1662] (#679) --- pyproject.toml | 4 +- .../agent/tools/context_tool.py | 315 ++++++++++------ .../retrievers/context_grounding_retriever.py | 43 ++- tests/agent/tools/test_context_tool.py | 356 ++++++++++++------ uv.lock | 10 +- 5 files changed, 476 insertions(+), 252 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1342465e6..47e483e1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath-langchain" -version = "0.8.20" +version = "0.8.21" 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.10.0, <2.11.0", "uipath-core>=0.5.2, <0.6.0", - "uipath-platform>=0.0.18, <0.1.0", + "uipath-platform>=0.0.23, <0.1.0", "uipath-runtime>=0.9.1, <0.10.0", "langgraph>=1.0.0, <2.0.0", "langchain-core>=1.2.11, <2.0.0", diff --git a/src/uipath_langchain/agent/tools/context_tool.py b/src/uipath_langchain/agent/tools/context_tool.py index 84f61d457..6eced4d92 100644 --- a/src/uipath_langchain/agent/tools/context_tool.py +++ b/src/uipath_langchain/agent/tools/context_tool.py @@ -1,15 +1,18 @@ """Context tool creation for semantic index retrieval.""" +import logging import uuid -from typing import Any, Dict, Optional +from typing import Any, Optional, cast +from jsonpath_ng import parse # type: ignore[import-untyped] from langchain_core.documents import Document from langchain_core.messages import ToolCall from langchain_core.tools import BaseTool, StructuredTool -from pydantic import BaseModel, Field, TypeAdapter, create_model +from pydantic import BaseModel, Field, create_model from uipath.agent.models.agent import ( AgentContextResourceConfig, AgentContextRetrievalMode, + AgentToolArgumentArgumentProperties, AgentToolArgumentProperties, ) from uipath.eval.mocks import mockable @@ -31,7 +34,10 @@ from uipath_langchain.agent.tools.internal_tools.schema_utils import ( BATCH_TRANSFORM_OUTPUT_SCHEMA, ) -from uipath_langchain.agent.tools.static_args import handle_static_args +from uipath_langchain.agent.tools.static_args import ( + ArgumentPropertiesMixin, + handle_static_args, +) from uipath_langchain.retrievers import ContextGroundingRetriever from .durable_interrupt import durable_interrupt @@ -42,44 +48,67 @@ from .tool_node import ToolWrapperReturnType from .utils import sanitize_tool_name -_ARG_PROPS_ADAPTER = TypeAdapter(Dict[str, AgentToolArgumentProperties]) +logger = logging.getLogger(__name__) -def _get_argument_properties( +def _build_arg_props_from_settings( resource: AgentContextResourceConfig, ) -> dict[str, AgentToolArgumentProperties]: - """Extract argumentProperties from the resource's extra fields. + """Build argument_properties from context resource settings. - AgentContextResourceConfig doesn't declare argument_properties yet, - but BaseCfg(extra="allow") preserves the raw JSON value. + Context resources don't receive argumentProperties from the frontend. + Instead, we derive them from the settings when variant="argument". + Only includes fields that belong in the tool's args_schema (i.e. query). """ - raw = ( - resource.model_extra.get("argumentProperties") if resource.model_extra else None - ) - if not raw: - return {} - return _ARG_PROPS_ADAPTER.validate_python(raw) + assert resource.settings is not None + arg_props: dict[str, AgentToolArgumentProperties] = {} + + if resource.settings.query and resource.settings.query.variant == "argument": + argument_path = (resource.settings.query.value or "").strip("{}") + arg_props["query"] = AgentToolArgumentArgumentProperties( + is_sensitive=False, + argument_path=argument_path, + ) + + return arg_props -def _build_folder_path_prefix_arg_props( +def _resolve_folder_path_prefix_from_state( resource: AgentContextResourceConfig, -) -> dict[str, Any]: - """Build argument_properties for folder_path_prefix from settings. + state: dict[str, Any], +) -> str | None: + """Resolve folder_path_prefix from agent state using jsonpath from settings.""" + assert resource.settings is not None + setting = resource.settings.folder_path_prefix + if not setting or setting.variant != "argument" or not setting.value: + return None + argument_path = "$." + setting.value.strip("{}") + matches = parse(argument_path).find(state) + return matches[0].value if matches else None - Fallback for when settings bag doesn't include argumentProperties - at the resource level but does set settings.folder_path_prefix - with variant="argument". - """ + +def _resolve_file_extension(resource: AgentContextResourceConfig) -> str | None: + """Resolve file extension from settings, returning None for 'All' or empty.""" + if resource.settings.file_extension and resource.settings.file_extension.value: + ext = resource.settings.file_extension.value + if ext.lower() == "all": + return None + return ext + return None + + +def _resolve_static_folder_path_prefix( + resource: AgentContextResourceConfig, +) -> str | None: + """Resolve static folder_path_prefix from settings.""" assert resource.settings is not None - assert resource.settings.folder_path_prefix is not None - argument_path = (resource.settings.folder_path_prefix.value or "").strip("{}") - return { - "folder_path_prefix": { - "variant": "argument", - "argumentPath": argument_path, - "isSensitive": False, - } - } + if ( + resource.settings.folder_path_prefix + and resource.settings.folder_path_prefix.value + and resource.settings.folder_path_prefix.variant == "static" + ): + return resource.settings.folder_path_prefix.value + return None def is_static_query(resource: AgentContextResourceConfig) -> bool: @@ -91,7 +120,6 @@ def is_static_query(resource: AgentContextResourceConfig) -> bool: def create_context_tool(resource: AgentContextResourceConfig) -> StructuredTool: - assert resource.settings is not None tool_name = sanitize_tool_name(resource.name) retrieval_mode = resource.settings.retrieval_mode.lower() if retrieval_mode == AgentContextRetrievalMode.DEEP_RAG.value.lower(): @@ -105,22 +133,20 @@ def create_context_tool(resource: AgentContextResourceConfig) -> StructuredTool: def handle_semantic_search( tool_name: str, resource: AgentContextResourceConfig ) -> StructuredTool: - assert resource.settings is not None ensure_valid_fields(resource) assert resource.settings.query.variant is not None - retriever = ContextGroundingRetriever( - index_name=resource.index_name, - folder_path=get_execution_folder_path(), - number_of_results=resource.settings.result_count, - ) + file_extension = _resolve_file_extension(resource) + static_folder_path_prefix = _resolve_static_folder_path_prefix(resource) static = is_static_query(resource) prompt = resource.settings.query.value if static else None if static: assert prompt is not None + arg_props = _build_arg_props_from_settings(resource) + class ContextOutputSchemaModel(BaseModel): documents: list[Document] = Field( ..., description="List of retrieved documents." @@ -128,16 +154,33 @@ class ContextOutputSchemaModel(BaseModel): output_model = ContextOutputSchemaModel - schema_fields: dict[str, Any] = ( - {} - if static - else { - "query": ( - str, - Field(..., description="The query to search for in the knowledge base"), + schema_fields: dict[str, Any] = {} + + if "query" in arg_props: + schema_fields["query"] = ( + str, + Field( + default=None, + description="The query to search for in the knowledge base", ), - } + ) + elif not static: + schema_fields["query"] = ( + str, + Field( + ..., + description="The query to search for in the knowledge base", + ), + ) + + has_arg_folder = ( + resource.settings.folder_path_prefix + and resource.settings.folder_path_prefix.variant == "argument" + and resource.settings.folder_path_prefix.value ) + + _resolved_arg_folder_prefix: str | None = None + input_model = create_model("SemanticSearchInput", **schema_fields) @mockable( @@ -147,7 +190,22 @@ class ContextOutputSchemaModel(BaseModel): output_schema=output_model.model_json_schema(), example_calls=[], # Examples cannot be provided for context. ) - async def context_tool_fn(query: Optional[str] = None) -> dict[str, Any]: + async def context_tool_fn( + query: Optional[str] = None, + ) -> dict[str, Any]: + resolved_folder_path_prefix = ( + static_folder_path_prefix or _resolved_arg_folder_prefix + ) + + retriever = ContextGroundingRetriever( + index_name=resource.index_name, + folder_path=get_execution_folder_path(), + number_of_results=resource.settings.result_count, + threshold=resource.settings.threshold, + scope_folder=resolved_folder_path_prefix, + scope_extension=file_extension, + ) + actual_query = prompt or query assert actual_query is not None docs = await retriever.ainvoke(actual_query) @@ -158,6 +216,39 @@ async def context_tool_fn(query: Optional[str] = None) -> dict[str, Any]: ] } + if arg_props or has_arg_folder: + + async def context_semantic_search_wrapper( + tool: BaseTool, + call: ToolCall, + state: AgentGraphState, + ) -> ToolWrapperReturnType: + call["args"] = handle_static_args( + cast(ArgumentPropertiesMixin, tool), state, call["args"] + ) + nonlocal _resolved_arg_folder_prefix + _resolved_arg_folder_prefix = _resolve_folder_path_prefix_from_state( + resource, dict(state) + ) + return await tool.ainvoke(call) + + tool = StructuredToolWithArgumentProperties( + name=tool_name, + description=resource.description, + args_schema=input_model, + coroutine=context_tool_fn, + output_type=output_model, + argument_properties=arg_props, + metadata={ + "tool_type": "context", + "display_name": resource.name, + "index_name": resource.index_name, + "context_retrieval_mode": resource.settings.retrieval_mode, + }, + ) + tool.set_tool_wrappers(awrapper=context_semantic_search_wrapper) + return tool + return StructuredToolWithOutputType( name=tool_name, description=resource.description, @@ -175,8 +266,7 @@ async def context_tool_fn(query: Optional[str] = None) -> dict[str, Any]: def handle_deep_rag( tool_name: str, resource: AgentContextResourceConfig -) -> StructuredTool: - assert resource.settings is not None +) -> StructuredToolWithArgumentProperties: ensure_valid_fields(resource) assert resource.settings.query.variant is not None @@ -196,17 +286,8 @@ def handle_deep_rag( if static: assert prompt is not None - static_folder_path_prefix = None - if ( - resource.settings.folder_path_prefix - and resource.settings.folder_path_prefix.value - and resource.settings.folder_path_prefix.variant == "static" - ): - static_folder_path_prefix = resource.settings.folder_path_prefix.value - - file_extension = None - if resource.settings.file_extension and resource.settings.file_extension.value: - file_extension = resource.settings.file_extension.value + static_folder_path_prefix = _resolve_static_folder_path_prefix(resource) + file_extension = _resolve_file_extension(resource) output_model = create_model( "DeepRagOutputModel", @@ -214,12 +295,7 @@ def handle_deep_rag( deep_rag_id=(str, Field(alias="deepRagId")), ) - arg_props = _get_argument_properties(resource) - - has_folder_path_prefix_arg = "folder_path_prefix" in arg_props or ( - resource.settings.folder_path_prefix - and resource.settings.folder_path_prefix.variant == "argument" - ) + arg_props = _build_arg_props_from_settings(resource) schema_fields: dict[str, Any] = ( {} @@ -235,19 +311,10 @@ def handle_deep_rag( } ) - if has_folder_path_prefix_arg: - schema_fields["folder_path_prefix"] = ( - str, - Field( - default=None, - description="The folder path prefix within the index to filter on", - ), - ) - if "folder_path_prefix" not in arg_props: - arg_props = _build_folder_path_prefix_arg_props(resource) - input_model = create_model("DeepRagInput", **schema_fields) + _resolved_arg_folder_prefix: str | None = None + @mockable( name=resource.name, description=resource.description, @@ -256,11 +323,11 @@ def handle_deep_rag( example_calls=[], # Examples cannot be provided for context. ) async def context_tool_fn( - query: Optional[str] = None, folder_path_prefix: Optional[str] = None + query: Optional[str] = None, ) -> dict[str, Any]: actual_prompt = prompt or query glob_pattern = build_glob_pattern( - folder_path_prefix=static_folder_path_prefix or folder_path_prefix, + folder_path_prefix=static_folder_path_prefix or _resolved_arg_folder_prefix, file_extension=file_extension, ) @@ -277,7 +344,21 @@ async def create_deep_rag(): return await create_deep_rag() - return StructuredToolWithArgumentProperties( + async def context_deep_rag_wrapper( + tool: BaseTool, + call: ToolCall, + state: AgentGraphState, + ) -> ToolWrapperReturnType: + nonlocal _resolved_arg_folder_prefix + call["args"] = handle_static_args( + cast(ArgumentPropertiesMixin, tool), state, call["args"] + ) + _resolved_arg_folder_prefix = _resolve_folder_path_prefix_from_state( + resource, dict(state) + ) + return await tool.ainvoke(call) + + tool = StructuredToolWithArgumentProperties( name=tool_name, description=resource.description, args_schema=input_model, @@ -291,12 +372,13 @@ async def create_deep_rag(): "context_retrieval_mode": resource.settings.retrieval_mode, }, ) + tool.set_tool_wrappers(awrapper=context_deep_rag_wrapper) + return tool def handle_batch_transform( tool_name: str, resource: AgentContextResourceConfig -) -> StructuredTool: - assert resource.settings is not None +) -> StructuredToolWithArgumentProperties: ensure_valid_fields(resource) assert resource.settings.query is not None @@ -339,20 +421,9 @@ def handle_batch_transform( if static: assert prompt is not None - static_folder_path_prefix = None - if ( - resource.settings.folder_path_prefix - and resource.settings.folder_path_prefix.value - and resource.settings.folder_path_prefix.variant == "static" - ): - static_folder_path_prefix = resource.settings.folder_path_prefix.value + static_folder_path_prefix = _resolve_static_folder_path_prefix(resource) - arg_props = _get_argument_properties(resource) - - has_folder_path_prefix_arg = "folder_path_prefix" in arg_props or ( - resource.settings.folder_path_prefix - and resource.settings.folder_path_prefix.variant == "argument" - ) + arg_props = _build_arg_props_from_settings(resource) output_model = create_model_from_schema(BATCH_TRANSFORM_OUTPUT_SCHEMA) @@ -372,18 +443,10 @@ def handle_batch_transform( description="The relative file path destination for the modified csv file", ), ) - if has_folder_path_prefix_arg: - schema_fields["folder_path_prefix"] = ( - str, - Field( - default=None, - description="The folder path prefix within the index to filter on", - ), - ) - if "folder_path_prefix" not in arg_props: - arg_props = _build_folder_path_prefix_arg_props(resource) input_model = create_model("BatchTransformInput", **schema_fields) + _resolved_arg_folder_prefix: str | None = None + @mockable( name=resource.name, description=resource.description, @@ -394,11 +457,10 @@ def handle_batch_transform( async def context_tool_fn( query: Optional[str] = None, destination_path: str = "output.csv", - folder_path_prefix: Optional[str] = None, ) -> dict[str, Any]: actual_prompt = prompt or query glob_pattern = build_glob_pattern( - folder_path_prefix=static_folder_path_prefix or folder_path_prefix, + folder_path_prefix=static_folder_path_prefix or _resolved_arg_folder_prefix, file_extension=None, ) @@ -441,7 +503,13 @@ async def context_batch_transform_wrapper( call: ToolCall, state: AgentGraphState, ) -> ToolWrapperReturnType: - call["args"] = handle_static_args(resource, state, call["args"]) + call["args"] = handle_static_args( + cast(ArgumentPropertiesMixin, tool), state, call["args"] + ) + nonlocal _resolved_arg_folder_prefix + _resolved_arg_folder_prefix = _resolve_folder_path_prefix_from_state( + resource, dict(state) + ) return await job_attachment_wrapper(tool, call, state) tool = StructuredToolWithArgumentProperties( @@ -464,7 +532,6 @@ async def context_batch_transform_wrapper( def ensure_valid_fields(resource_config: AgentContextResourceConfig): - assert resource_config.settings is not None if not resource_config.settings.query.variant: raise AgentStartupError( code=AgentStartupErrorCode.INVALID_TOOL_CONFIG, @@ -482,29 +549,35 @@ def ensure_valid_fields(resource_config: AgentContextResourceConfig): ) +def _normalize_folder_prefix(folder_path_prefix: str | None) -> str: + """Normalize a folder path prefix to a clean directory-only pattern. + + Strips leading/trailing slashes and trailing file-matching globs + (e.g. /*, /**, /**/*) since the caller appends the file extension part. + """ + if not folder_path_prefix: + return "**" + + prefix = folder_path_prefix.strip("/").rstrip("/*") + if not prefix: + return "**" + + return prefix + + def build_glob_pattern( folder_path_prefix: str | None, file_extension: str | None ) -> str: - # Handle prefix - prefix = "**" - if folder_path_prefix: - prefix = folder_path_prefix.rstrip("/") - - if not prefix.startswith("**"): - if prefix.startswith("/"): - prefix = prefix[1:] + prefix = _normalize_folder_prefix(folder_path_prefix) # Handle extension extension = "*" if file_extension: ext = file_extension.lower() - if ext in {"pdf", "txt", "docx", "csv"}: - extension = f"*.{ext}" - else: - extension = f"*.{ext}" + extension = f"*.{ext}" # Final pattern logic - if not prefix or prefix == "**": + if prefix == "**": return "**/*" if extension == "*" else f"**/{extension}" return f"{prefix}/{extension}" diff --git a/src/uipath_langchain/retrievers/context_grounding_retriever.py b/src/uipath_langchain/retrievers/context_grounding_retriever.py index 0a7f02c95..dc448c6ad 100644 --- a/src/uipath_langchain/retrievers/context_grounding_retriever.py +++ b/src/uipath_langchain/retrievers/context_grounding_retriever.py @@ -5,6 +5,7 @@ from langchain_core.documents import Document from langchain_core.retrievers import BaseRetriever from uipath.platform import UiPath +from uipath.platform.context_grounding import UnifiedSearchScope class ContextGroundingRetriever(BaseRetriever): @@ -13,48 +14,74 @@ class ContextGroundingRetriever(BaseRetriever): folder_key: str | None = None uipath_sdk: UiPath | None = None number_of_results: int | None = 10 + threshold: float = 0.0 + scope_folder: str | None = None + scope_extension: str | None = None + + def _build_scope(self) -> UnifiedSearchScope | None: + if self.scope_folder or self.scope_extension: + return UnifiedSearchScope( + folder=self.scope_folder, + extension=self.scope_extension, + ) + return None def _get_relevant_documents( self, query: str, *, run_manager: CallbackManagerForRetrieverRun ) -> list[Document]: - """Sync implementations for retriever calls context_grounding API to search the requested index.""" + """Sync implementation calls context_grounding unified_search API.""" sdk = self.uipath_sdk if self.uipath_sdk is not None else UiPath() - results = sdk.context_grounding.search( + result = sdk.context_grounding.unified_search( self.index_name, query, - self.number_of_results if self.number_of_results is not None else 10, + number_of_results=self.number_of_results + if self.number_of_results is not None + else 10, + threshold=self.threshold, + scope=self._build_scope(), folder_path=self.folder_path, folder_key=self.folder_key, ) + values = result.semantic_results.values if result.semantic_results else [] + return [ Document( page_content=x.content, metadata={ "source": x.source, + "search_id": result.metadata.operation_id + if result.metadata + else None, "reference": x.reference, "page_number": x.page_number, "score": x.score, }, ) - for x in results + for x in values ] async def _aget_relevant_documents( self, query: str, *, run_manager: AsyncCallbackManagerForRetrieverRun ) -> list[Document]: - """Async implementations for retriever calls context_grounding API to search the requested index.""" + """Async implementation calls context_grounding unified_search_async API.""" sdk = self.uipath_sdk if self.uipath_sdk is not None else UiPath() - results = await sdk.context_grounding.search_async( + result = await sdk.context_grounding.unified_search_async( self.index_name, query, - self.number_of_results if self.number_of_results is not None else 10, + number_of_results=self.number_of_results + if self.number_of_results is not None + else 10, + threshold=self.threshold, + scope=self._build_scope(), folder_path=self.folder_path, folder_key=self.folder_key, ) + values = result.semantic_results.values if result.semantic_results else [] + return [ Document( page_content=x.content, @@ -65,5 +92,5 @@ async def _aget_relevant_documents( "score": x.score, }, ) - for x in results + for x in values ] diff --git a/tests/agent/tools/test_context_tool.py b/tests/agent/tools/test_context_tool.py index 47f207f50..eaaabf00d 100644 --- a/tests/agent/tools/test_context_tool.py +++ b/tests/agent/tools/test_context_tool.py @@ -20,54 +20,63 @@ from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode from uipath_langchain.agent.tools.context_tool import ( + _normalize_folder_prefix, build_glob_pattern, create_context_tool, handle_batch_transform, handle_deep_rag, handle_semantic_search, ) +from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( + StructuredToolWithArgumentProperties, +) from uipath_langchain.agent.tools.structured_tool_with_output_type import ( StructuredToolWithOutputType, ) +def _make_context_resource( + name="test_tool", + description="Test tool", + index_name="test-index", + folder_path="/test/folder", + query_value=None, + query_variant="static", + citation_mode_value=None, + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + folder_path_prefix=None, + **kwargs, +): + """Helper to create an AgentContextResourceConfig.""" + 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=query_variant, + ), + citation_mode=citation_mode_value, + folder_path_prefix=folder_path_prefix, + ), + is_enabled=True, + **kwargs, + ) + + 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, - query_variant="static", - 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=query_variant, - ), - citation_mode=citation_mode_value, - ), - is_enabled=True, - ) - - return _create_config + return _make_context_resource def test_successful_deep_rag_creation(self, base_resource_config): """Test successful creation of Deep RAG tool with all required fields.""" @@ -78,9 +87,9 @@ def test_successful_deep_rag_creation(self, base_resource_config): result = handle_deep_rag("test_deep_rag", resource) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) assert result.name == "test_deep_rag" - assert result.description == "Test Deep RAG tool" + assert result.description == "Test tool" assert hasattr(result.args_schema, "model_json_schema") assert result.args_schema.model_json_schema()["properties"] == {} assert issubclass(result.output_type, DeepRagContent) @@ -88,6 +97,35 @@ def test_successful_deep_rag_creation(self, base_resource_config): assert "deepRagId" in schema["properties"] assert schema["properties"]["deepRagId"]["type"] == "string" + def test_deep_rag_has_tool_wrapper(self, base_resource_config): + """Test that Deep RAG tool has a tool wrapper for static args resolution.""" + resource = base_resource_config( + citation_mode_value=AgentContextValueSetting(value="Inline"), + query_value="some query", + ) + + result = handle_deep_rag("test_deep_rag", resource) + + assert result.awrapper is not None + + def test_deep_rag_with_folder_path_prefix_from_settings(self, base_resource_config): + """Test that folder_path_prefix with argument variant is resolved in wrapper, not via argument_properties.""" + resource = base_resource_config( + citation_mode_value=AgentContextValueSetting(value="Inline"), + query_value="some query", + folder_path_prefix=AgentContextQuerySetting( + value="{deepRagFolderPrefix}", variant="argument" + ), + ) + + result = handle_deep_rag("test_deep_rag", resource) + + assert isinstance(result, StructuredToolWithArgumentProperties) + # folder_path_prefix is resolved directly in the wrapper from state, + # not via argument_properties or args_schema + assert "folder_path_prefix" not in result.argument_properties + assert isinstance(result.args_schema, type) + def test_missing_static_query_value_raises_error(self, base_resource_config): """Test that missing query.value for static variant raises AgentStartupError.""" resource = base_resource_config(query_variant="static", query_value=None) @@ -139,7 +177,7 @@ def test_citation_mode_conversion( result = handle_deep_rag("test_deep_rag", resource) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) def test_tool_name_preserved(self, base_resource_config): """Test that the sanitized tool name is correctly applied.""" @@ -228,9 +266,9 @@ def test_dynamic_query_deep_rag_creation(self, base_resource_config): result = handle_deep_rag("test_deep_rag", resource) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) assert result.name == "test_deep_rag" - assert result.description == "Test Deep RAG tool" + assert result.description == "Test tool" assert result.args_schema is not None # Dynamic has input schema assert issubclass(result.output_type, DeepRagContent) @@ -300,44 +338,23 @@ class TestCreateContextTool: @pytest.fixture def semantic_search_config(self): """Fixture for semantic search configuration.""" - return AgentContextResourceConfig( + return _make_context_resource( 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, - query=AgentContextQuerySetting( - value=None, - description="Query for semantic search", - variant="dynamic", - ), - ), - is_enabled=True, + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + query_variant="dynamic", ) @pytest.fixture def deep_rag_config(self): """Fixture for deep RAG configuration.""" - return AgentContextResourceConfig( + return _make_context_resource( 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, + retrieval_mode=AgentContextRetrievalMode.DEEP_RAG, + query_value="test query", + query_variant="static", + citation_mode_value=AgentContextValueSetting(value="Inline"), ) def test_create_semantic_search_tool(self, semantic_search_config): @@ -352,7 +369,7 @@ 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 isinstance(result, StructuredToolWithArgumentProperties) assert result.name == "test_deep_rag" assert hasattr(result.args_schema, "model_json_schema") assert result.args_schema.model_json_schema()["properties"] == {} @@ -361,14 +378,14 @@ def test_create_deep_rag_tool(self, deep_rag_config): 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" + deep_rag_config.settings.retrieval_mode = "DEEPRAG" result = create_context_tool(deep_rag_config) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) # Test with mixed case - deep_rag_config.settings.retrieval_mode = "Deep_Rag" + deep_rag_config.settings.retrieval_mode = "deeprag" result = create_context_tool(deep_rag_config) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) class TestHandleSemanticSearch: @@ -377,22 +394,11 @@ class TestHandleSemanticSearch: @pytest.fixture def semantic_config(self): """Fixture for semantic search configuration.""" - return AgentContextResourceConfig( + return _make_context_resource( 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, - query=AgentContextQuerySetting( - value=None, - description="Query for semantic search", - variant="dynamic", - ), - ), - is_enabled=True, + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + query_variant="dynamic", ) def test_semantic_search_tool_creation(self, semantic_config): @@ -445,22 +451,12 @@ async def test_semantic_search_returns_documents(self, semantic_config): def test_static_query_semantic_search_creation(self): """Test successful creation of semantic search tool with static query.""" - resource = AgentContextResourceConfig( + resource = _make_context_resource( 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, - query=AgentContextQuerySetting( - value="predefined static query", - description="Static query for semantic search", - variant="static", - ), - ), - is_enabled=True, + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + query_value="predefined static query", + query_variant="static", ) result = handle_semantic_search("semantic_tool", resource) @@ -474,22 +470,12 @@ def test_static_query_semantic_search_creation(self): @pytest.mark.asyncio async def test_static_query_uses_predefined_query(self): """Test that static query variant uses the predefined query value.""" - resource = AgentContextResourceConfig( + resource = _make_context_resource( 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, - query=AgentContextQuerySetting( - value="predefined static query", - description="Static query for semantic search", - variant="static", - ), - ), - is_enabled=True, + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + query_value="predefined static query", + query_variant="static", ) mock_documents = [ @@ -512,13 +498,20 @@ async def test_static_query_uses_predefined_query(self): assert "documents" in result assert len(result["documents"]) == 1 + @pytest.mark.asyncio @patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/TestFolder"}) - def test_semantic_search_uses_execution_folder_path(self, semantic_config): + async def test_semantic_search_uses_execution_folder_path(self, semantic_config): """Test that ContextGroundingRetriever receives folder_path from the execution environment.""" with patch( "uipath_langchain.agent.tools.context_tool.ContextGroundingRetriever" ) as mock_retriever_class: - handle_semantic_search("semantic_tool", semantic_config) + mock_retriever = AsyncMock() + mock_retriever.ainvoke.return_value = [] + mock_retriever_class.return_value = mock_retriever + + tool = handle_semantic_search("semantic_tool", semantic_config) + assert tool.coroutine is not None + await tool.coroutine(query="test query") call_kwargs = mock_retriever_class.call_args[1] assert call_kwargs["folder_path"] == "/Shared/TestFolder" @@ -561,7 +554,7 @@ def test_static_query_batch_transform_creation(self, batch_transform_config): """Test successful creation of batch transform tool with static query.""" result = handle_batch_transform("batch_transform_tool", batch_transform_config) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) assert result.name == "batch_transform_tool" assert result.description == "Batch transform tool" assert result.args_schema is not None # Has destination_path parameter @@ -611,7 +604,7 @@ def test_dynamic_query_batch_transform_creation(self): result = handle_batch_transform("batch_transform_tool", resource) - assert isinstance(result, StructuredToolWithOutputType) + assert isinstance(result, StructuredToolWithArgumentProperties) assert result.name == "batch_transform_tool" assert result.args_schema is not None output_schema = result.output_type.model_json_schema() @@ -652,6 +645,43 @@ def test_dynamic_query_batch_transform_has_both_parameters(self): assert "query" in schema["properties"] assert "destination_path" in schema["properties"] + def test_batch_transform_with_folder_path_prefix_from_settings(self): + """Test that batch transform builds argument_properties from settings.""" + resource = AgentContextResourceConfig( + name="batch_transform_tool", + description="Batch transform tool", + resource_type="context", + index_name="test-index", + folder_path="/test/folder", + settings=AgentContextSettings( + result_count=5, + retrieval_mode=AgentContextRetrievalMode.BATCH_TRANSFORM, + query=AgentContextQuerySetting( + value="transform query", + description="Static query", + variant="static", + ), + web_search_grounding=AgentContextValueSetting(value="enabled"), + output_columns=[ + AgentContextOutputColumn( + name="output_col1", description="First output column" + ), + ], + folder_path_prefix=AgentContextQuerySetting( + value="{batchFolderPrefix}", variant="argument" + ), + ), + is_enabled=True, + ) + + result = handle_batch_transform("batch_transform_tool", resource) + + assert isinstance(result, StructuredToolWithArgumentProperties) + # folder_path_prefix is resolved directly in the wrapper from state, + # not via argument_properties or args_schema + assert "folder_path_prefix" not in result.argument_properties + assert isinstance(result.args_schema, type) + @pytest.mark.asyncio async def test_static_query_batch_transform_uses_predefined_query( self, batch_transform_config @@ -907,3 +937,97 @@ def test_supported_extensions(self, ext): def test_unsupported_extension_still_works(self): """Extensions outside the named set are handled identically.""" assert build_glob_pattern("data", "xlsx") == "data/*.xlsx" + + # --- Trailing file-matching globs stripped --- + + def test_prefix_with_trailing_star(self): + """Trailing /* is stripped since extension is appended separately.""" + assert build_glob_pattern("documents/*", "pdf") == "documents/*.pdf" + + def test_prefix_with_trailing_double_star(self): + """Trailing /** is stripped.""" + assert build_glob_pattern("documents/**", "pdf") == "documents/*.pdf" + + def test_prefix_with_trailing_double_star_star(self): + """Trailing /**/* is stripped.""" + assert build_glob_pattern("documents/**/*", "pdf") == "documents/*.pdf" + + def test_match_all_glob_treated_as_no_prefix(self): + """/**/* is a match-all pattern and should be treated as no prefix.""" + assert build_glob_pattern("/**/*", "pdf") == "**/*.pdf" + + def test_star_slash_star_treated_as_no_prefix(self): + """*/* is a match-all pattern and should be treated as no prefix.""" + assert build_glob_pattern("*/*", "pdf") == "**/*.pdf" + + def test_double_star_slash_star_treated_as_no_prefix(self): + """**/* is a match-all pattern and should be treated as no prefix.""" + assert build_glob_pattern("**/*", "pdf") == "**/*.pdf" + + +class TestNormalizeFolderPrefix: + """Test cases for _normalize_folder_prefix function.""" + + # --- None / empty --- + + def test_none_returns_double_star(self): + assert _normalize_folder_prefix(None) == "**" + + def test_empty_string_returns_double_star(self): + assert _normalize_folder_prefix("") == "**" + + def test_only_slashes_returns_double_star(self): + assert _normalize_folder_prefix("///") == "**" + + # --- Leading/trailing slash stripping --- + + def test_strips_leading_slash(self): + assert _normalize_folder_prefix("/documents") == "documents" + + def test_strips_trailing_slash(self): + assert _normalize_folder_prefix("documents/") == "documents" + + def test_strips_both_slashes(self): + assert _normalize_folder_prefix("/documents/") == "documents" + + # --- Trailing glob stripping --- + + def test_strips_trailing_star(self): + assert _normalize_folder_prefix("documents/*") == "documents" + + def test_strips_trailing_double_star(self): + assert _normalize_folder_prefix("documents/**") == "documents" + + def test_strips_trailing_double_star_star(self): + assert _normalize_folder_prefix("documents/**/*") == "documents" + + def test_nested_prefix_strips_trailing_glob(self): + assert _normalize_folder_prefix("folder/subfolder/*") == "folder/subfolder" + + def test_nested_prefix_strips_trailing_double_star_star(self): + assert _normalize_folder_prefix("folder/subfolder/**/*") == "folder/subfolder" + + # --- Match-all patterns become ** --- + + def test_star_slash_star_returns_double_star(self): + assert _normalize_folder_prefix("*/*") == "**" + + def test_double_star_slash_star_returns_double_star(self): + assert _normalize_folder_prefix("**/*") == "**" + + def test_slash_double_star_slash_star_returns_double_star(self): + assert _normalize_folder_prefix("/**/*") == "**" + + # --- Preserves valid prefixes --- + + def test_simple_prefix(self): + assert _normalize_folder_prefix("folder") == "folder" + + def test_nested_prefix(self): + assert _normalize_folder_prefix("folder/subfolder") == "folder/subfolder" + + def test_double_star_prefix_preserved(self): + assert _normalize_folder_prefix("**/documents") == "**/documents" + + def test_double_star_nested_prefix_preserved(self): + assert _normalize_folder_prefix("**/docs/reports") == "**/docs/reports" diff --git a/uv.lock b/uv.lock index fcff15fdf..701cc06cf 100644 --- a/uv.lock +++ b/uv.lock @@ -3289,7 +3289,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.13" +version = "2.10.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -3312,9 +3312,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/3a/3a93f5c54078b993e6cecdae6069ebafe122fceb639b1f59ad0aa3f1b765/uipath-2.10.13.tar.gz", hash = "sha256:13795c00dfb7391f248efb6ae4b96f096a4a8131d0df5f8ec0b7f265be1d0e10", size = 2456921, upload-time = "2026-03-13T07:51:16.484Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/28/7eac19f5a20a5658690ad5d30d237e414a288444b30cf65333b26dda9731/uipath-2.10.11.tar.gz", hash = "sha256:28c72ead96f5bff8d4dc721e97f0b546eebc76184f6ab4b4a2c95f6f0e9d4db1", size = 2455334, upload-time = "2026-03-09T14:29:15.275Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/f9/c8745f866a39047f529ee0413aedd8fbf81f7ffd5dd46862cb5e6221d58d/uipath-2.10.13-py3-none-any.whl", hash = "sha256:fc1c8503b9cc3538cf3003cb10e1e3e736529aba4272086066402fde21154b65", size = 357769, upload-time = "2026-03-13T07:51:14.599Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0a/49d0904346ce70440b6aee6a98df1252b58a2d72f2f54db41fd213dcf980/uipath-2.10.11-py3-none-any.whl", hash = "sha256:7187df5536084c2097d7b2340bb99f545a4d914ca5533abf0b21bd2c3822c5b6", size = 357297, upload-time = "2026-03-09T14:29:13.213Z" }, ] [[package]] @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.20" +version = "0.8.21" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -3402,7 +3402,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "uipath", specifier = ">=2.10.0,<2.11.0" }, { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, - { name = "uipath-platform", specifier = ">=0.0.18,<0.1.0" }, + { name = "uipath-platform", specifier = ">=0.0.23,<0.1.0" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, ] provides-extras = ["vertex", "bedrock"] From ad49d4866a9c7e54451318941343a91c0846015a Mon Sep 17 00:00:00 2001 From: Cosmin Staicu Date: Fri, 13 Mar 2026 19:51:30 +0200 Subject: [PATCH 09/33] chore: avoid imds timeout in bedrock client init (#690) --- .github/workflows/commitlint.yml | 29 +------------------ pyproject.toml | 4 +-- .../agent/tools/context_tool.py | 12 ++++++-- src/uipath_langchain/chat/bedrock.py | 8 +++-- tests/chat/test_bedrock.py | 6 ++-- uv.lock | 10 +++---- 6 files changed, 26 insertions(+), 43 deletions(-) diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 8562e455d..b34ce5fa9 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -16,32 +16,5 @@ jobs: with: fetch-depth: 0 - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version: 22 - - - name: Install Git - run: | - if ! command -v git &> /dev/null; then - echo "Git is not installed. Installing..." - sudo apt-get update - sudo apt-get install -y git - else - echo "Git is already installed." - fi - - - name: Install commitlint - run: | - npm install conventional-changelog-conventionalcommits - npm install commitlint@latest - npm install @commitlint/config-conventional - - - name: Configure - run: | - echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js - - name: Validate PR commits with commitlint - run: | - git fetch origin pull/${{ github.event.pull_request.number }}/head:pr_branch - npx commitlint --from ${{ github.event.pull_request.base.sha }} --to pr_branch --verbose + uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6 diff --git a/pyproject.toml b/pyproject.toml index 47e483e1c..232eacc81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-langchain" -version = "0.8.21" +version = "0.8.22" 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.10.0, <2.11.0", + "uipath>=2.10.13, <2.11.0", "uipath-core>=0.5.2, <0.6.0", "uipath-platform>=0.0.23, <0.1.0", "uipath-runtime>=0.9.1, <0.10.0", diff --git a/src/uipath_langchain/agent/tools/context_tool.py b/src/uipath_langchain/agent/tools/context_tool.py index 6eced4d92..51837c45c 100644 --- a/src/uipath_langchain/agent/tools/context_tool.py +++ b/src/uipath_langchain/agent/tools/context_tool.py @@ -89,6 +89,7 @@ def _resolve_folder_path_prefix_from_state( def _resolve_file_extension(resource: AgentContextResourceConfig) -> str | None: """Resolve file extension from settings, returning None for 'All' or empty.""" + assert resource.settings is not None if resource.settings.file_extension and resource.settings.file_extension.value: ext = resource.settings.file_extension.value if ext.lower() == "all": @@ -121,6 +122,7 @@ def is_static_query(resource: AgentContextResourceConfig) -> bool: def create_context_tool(resource: AgentContextResourceConfig) -> StructuredTool: tool_name = sanitize_tool_name(resource.name) + assert resource.settings is not None retrieval_mode = resource.settings.retrieval_mode.lower() if retrieval_mode == AgentContextRetrievalMode.DEEP_RAG.value.lower(): return handle_deep_rag(tool_name, resource) @@ -134,11 +136,14 @@ def handle_semantic_search( tool_name: str, resource: AgentContextResourceConfig ) -> StructuredTool: ensure_valid_fields(resource) + assert resource.settings is not None assert resource.settings.query.variant is not None file_extension = _resolve_file_extension(resource) static_folder_path_prefix = _resolve_static_folder_path_prefix(resource) + result_count = resource.settings.result_count + threshold = resource.settings.threshold static = is_static_query(resource) prompt = resource.settings.query.value if static else None @@ -200,8 +205,8 @@ async def context_tool_fn( retriever = ContextGroundingRetriever( index_name=resource.index_name, folder_path=get_execution_folder_path(), - number_of_results=resource.settings.result_count, - threshold=resource.settings.threshold, + number_of_results=result_count, + threshold=threshold, scope_folder=resolved_folder_path_prefix, scope_extension=file_extension, ) @@ -268,6 +273,7 @@ def handle_deep_rag( tool_name: str, resource: AgentContextResourceConfig ) -> StructuredToolWithArgumentProperties: ensure_valid_fields(resource) + assert resource.settings is not None assert resource.settings.query.variant is not None @@ -380,6 +386,7 @@ def handle_batch_transform( tool_name: str, resource: AgentContextResourceConfig ) -> StructuredToolWithArgumentProperties: ensure_valid_fields(resource) + assert resource.settings is not None assert resource.settings.query is not None assert resource.settings.query.variant is not None @@ -532,6 +539,7 @@ async def context_batch_transform_wrapper( def ensure_valid_fields(resource_config: AgentContextResourceConfig): + assert resource_config.settings is not None if not resource_config.settings.query.variant: raise AgentStartupError( code=AgentStartupErrorCode.INVALID_TOOL_CONFIG, diff --git a/src/uipath_langchain/chat/bedrock.py b/src/uipath_langchain/chat/bedrock.py index 1c0c4d70d..8ddfb4642 100644 --- a/src/uipath_langchain/chat/bedrock.py +++ b/src/uipath_langchain/chat/bedrock.py @@ -96,11 +96,13 @@ def _capture_response_headers(self, parsed, model, **kwargs): self.header_capture.set(dict(headers)) def get_client(self): - client = boto3.client( - "bedrock-runtime", - region_name="none", + session = boto3.Session( aws_access_key_id="none", aws_secret_access_key="none", + region_name="none", + ) + client = session.client( + "bedrock-runtime", config=botocore.config.Config( retries={ "total_max_attempts": 1, diff --git a/tests/chat/test_bedrock.py b/tests/chat/test_bedrock.py index e60adfd89..123666015 100644 --- a/tests/chat/test_bedrock.py +++ b/tests/chat/test_bedrock.py @@ -1,5 +1,5 @@ import os -from unittest.mock import MagicMock, patch +from unittest.mock import patch from langchain_aws import ChatBedrock from langchain_core.messages import AIMessage, BaseMessage, HumanMessage @@ -43,8 +43,8 @@ class TestGenerate: "UIPATH_ACCESS_TOKEN": "token", }, ) - @patch("uipath_langchain.chat.bedrock.boto3.client", return_value=MagicMock()) - def test_generate_converts_file_blocks(self, _mock_boto): + @patch("uipath_langchain.chat.bedrock.boto3.Session") + def test_generate_converts_file_blocks(self, mock_session_cls): chat = UiPathChatBedrock() messages: list[BaseMessage] = [ diff --git a/uv.lock b/uv.lock index 701cc06cf..860a561bb 100644 --- a/uv.lock +++ b/uv.lock @@ -3289,7 +3289,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.11" +version = "2.10.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -3312,9 +3312,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/28/7eac19f5a20a5658690ad5d30d237e414a288444b30cf65333b26dda9731/uipath-2.10.11.tar.gz", hash = "sha256:28c72ead96f5bff8d4dc721e97f0b546eebc76184f6ab4b4a2c95f6f0e9d4db1", size = 2455334, upload-time = "2026-03-09T14:29:15.275Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/3a/3a93f5c54078b993e6cecdae6069ebafe122fceb639b1f59ad0aa3f1b765/uipath-2.10.13.tar.gz", hash = "sha256:13795c00dfb7391f248efb6ae4b96f096a4a8131d0df5f8ec0b7f265be1d0e10", size = 2456921, upload-time = "2026-03-13T07:51:16.484Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/0a/49d0904346ce70440b6aee6a98df1252b58a2d72f2f54db41fd213dcf980/uipath-2.10.11-py3-none-any.whl", hash = "sha256:7187df5536084c2097d7b2340bb99f545a4d914ca5533abf0b21bd2c3822c5b6", size = 357297, upload-time = "2026-03-09T14:29:13.213Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f9/c8745f866a39047f529ee0413aedd8fbf81f7ffd5dd46862cb5e6221d58d/uipath-2.10.13-py3-none-any.whl", hash = "sha256:fc1c8503b9cc3538cf3003cb10e1e3e736529aba4272086066402fde21154b65", size = 357769, upload-time = "2026-03-13T07:51:14.599Z" }, ] [[package]] @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.21" +version = "0.8.22" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -3400,7 +3400,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.10.0,<2.11.0" }, + { name = "uipath", specifier = ">=2.10.13,<2.11.0" }, { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, { name = "uipath-platform", specifier = ">=0.0.23,<0.1.0" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, From 8fecff65030f3d62551a73deabf239b40b62dd24 Mon Sep 17 00:00:00 2001 From: Cosmin Staicu Date: Sat, 14 Mar 2026 13:04:20 +0200 Subject: [PATCH 10/33] chore: use botocore.UNSIGNED to prevent IMDS timeout in bedrock (#692) --- pyproject.toml | 2 +- src/uipath_langchain/chat/bedrock.py | 1 + tests/chat/test_bedrock.py | 28 +++++++++++++++++++++++++++- uv.lock | 2 +- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 232eacc81..ea82ce308 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.22" +version = "0.8.23" 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/chat/bedrock.py b/src/uipath_langchain/chat/bedrock.py index 8ddfb4642..64b08897a 100644 --- a/src/uipath_langchain/chat/bedrock.py +++ b/src/uipath_langchain/chat/bedrock.py @@ -108,6 +108,7 @@ def get_client(self): "total_max_attempts": 1, }, read_timeout=300, + signature_version=botocore.UNSIGNED, ), ) client.meta.events.register( diff --git a/tests/chat/test_bedrock.py b/tests/chat/test_bedrock.py index 123666015..dd66df4ff 100644 --- a/tests/chat/test_bedrock.py +++ b/tests/chat/test_bedrock.py @@ -1,12 +1,38 @@ +import logging import os from unittest.mock import patch +import botocore from langchain_aws import ChatBedrock from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from langchain_core.messages.content import create_file_block from langchain_core.outputs import ChatGeneration, ChatResult -from uipath_langchain.chat.bedrock import UiPathChatBedrock +from uipath_langchain.chat.bedrock import ( + AwsBedrockCompletionsPassthroughClient, + UiPathChatBedrock, +) + + +class TestGetClientSkipsImds: + def test_client_creation_does_not_trigger_credential_resolution(self, caplog): + passthrough = AwsBedrockCompletionsPassthroughClient( + model="anthropic.claude-haiku-4-5-20251001", + token="test-token", + api_flavor="converse", + ) + + with caplog.at_level(logging.DEBUG, logger="botocore"): + client = passthrough.get_client() + + assert caplog.records + credential_log_records = [ + r for r in caplog.records if r.name.startswith("botocore.credentials") + ] + assert not credential_log_records, ( + f"Unexpected credential resolution: {[r.getMessage() for r in credential_log_records]}" + ) + assert client._request_signer._signature_version == botocore.UNSIGNED class TestConvertFileBlocksToAnthropicDocuments: diff --git a/uv.lock b/uv.lock index 860a561bb..466278460 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.22" +version = "0.8.23" source = { editable = "." } dependencies = [ { name = "httpx" }, From 899a056d210aae0b8d5ffbf37eaa991978538cc8 Mon Sep 17 00:00:00 2001 From: Cosmin Staicu Date: Sat, 14 Mar 2026 17:56:48 +0200 Subject: [PATCH 11/33] chore: pass bedrock_client with UNSIGNED to prevent IMDS timeout from langchain_aws (#693) --- pyproject.toml | 2 +- src/uipath_langchain/chat/bedrock.py | 35 ++++++++++----- tests/chat/test_bedrock.py | 66 +++++++++++++++++++++++++++- uv.lock | 2 +- 4 files changed, 90 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ea82ce308..b7cbe8857 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.23" +version = "0.8.24" 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/chat/bedrock.py b/src/uipath_langchain/chat/bedrock.py index 64b08897a..7c5666c09 100644 --- a/src/uipath_langchain/chat/bedrock.py +++ b/src/uipath_langchain/chat/bedrock.py @@ -95,20 +95,26 @@ def _capture_response_headers(self, parsed, model, **kwargs): if self.header_capture: self.header_capture.set(dict(headers)) - def get_client(self): - session = boto3.Session( + def _build_session(self): + return boto3.Session( aws_access_key_id="none", aws_secret_access_key="none", region_name="none", ) + + def _unsigned_config(self, **overrides): + return botocore.config.Config( + signature_version=botocore.UNSIGNED, + **overrides, + ) + + def get_client(self): + session = self._build_session() client = session.client( "bedrock-runtime", - config=botocore.config.Config( - retries={ - "total_max_attempts": 1, - }, + config=self._unsigned_config( + retries={"total_max_attempts": 1}, read_timeout=300, - signature_version=botocore.UNSIGNED, ), ) client.meta.events.register( @@ -119,6 +125,13 @@ def get_client(self): ) return client + def get_bedrock_client(self): + session = self._build_session() + return session.client( + "bedrock", + config=self._unsigned_config(), + ) + def _modify_request(self, request, **kwargs): """Intercept boto3 request and redirect to LLM Gateway.""" # Detect streaming based on URL suffix: @@ -186,8 +199,8 @@ def __init__( byo_connection_id=byo_connection_id, ) - client = passthrough_client.get_client() - kwargs["client"] = client + kwargs["client"] = passthrough_client.get_client() + kwargs["bedrock_client"] = passthrough_client.get_bedrock_client() kwargs["model"] = model_name super().__init__(**kwargs) self.model = model_name @@ -251,8 +264,8 @@ def __init__( header_capture=header_capture, ) - client = passthrough_client.get_client() - kwargs["client"] = client + kwargs["client"] = passthrough_client.get_client() + kwargs["bedrock_client"] = passthrough_client.get_bedrock_client() kwargs["model"] = model_name kwargs["header_capture"] = header_capture super().__init__(**kwargs) diff --git a/tests/chat/test_bedrock.py b/tests/chat/test_bedrock.py index dd66df4ff..e4b15431f 100644 --- a/tests/chat/test_bedrock.py +++ b/tests/chat/test_bedrock.py @@ -11,11 +11,22 @@ from uipath_langchain.chat.bedrock import ( AwsBedrockCompletionsPassthroughClient, UiPathChatBedrock, + UiPathChatBedrockConverse, ) class TestGetClientSkipsImds: - def test_client_creation_does_not_trigger_credential_resolution(self, caplog): + def _assert_no_credential_resolution(self, caplog, client): + assert caplog.records + credential_log_records = [ + r for r in caplog.records if r.name.startswith("botocore.credentials") + ] + assert not credential_log_records, ( + f"Unexpected credential resolution: {[r.getMessage() for r in credential_log_records]}" + ) + assert client._request_signer._signature_version == botocore.UNSIGNED + + def test_get_client_does_not_trigger_credential_resolution(self, caplog): passthrough = AwsBedrockCompletionsPassthroughClient( model="anthropic.claude-haiku-4-5-20251001", token="test-token", @@ -25,6 +36,58 @@ def test_client_creation_does_not_trigger_credential_resolution(self, caplog): with caplog.at_level(logging.DEBUG, logger="botocore"): client = passthrough.get_client() + self._assert_no_credential_resolution(caplog, client) + + def test_get_bedrock_client_does_not_trigger_credential_resolution(self, caplog): + passthrough = AwsBedrockCompletionsPassthroughClient( + model="anthropic.claude-haiku-4-5-20251001", + token="test-token", + api_flavor="converse", + ) + + with caplog.at_level(logging.DEBUG, logger="botocore"): + client = passthrough.get_bedrock_client() + + self._assert_no_credential_resolution(caplog, client) + + @patch.dict( + os.environ, + { + "UIPATH_URL": "https://example.com", + "UIPATH_ORGANIZATION_ID": "org", + "UIPATH_TENANT_ID": "tenant", + "UIPATH_ACCESS_TOKEN": "token", + }, + ) + def test_uipath_chat_bedrock_converse_init_does_not_trigger_credential_resolution( + self, caplog + ): + with caplog.at_level(logging.DEBUG, logger="botocore"): + UiPathChatBedrockConverse() + + assert caplog.records + credential_log_records = [ + r for r in caplog.records if r.name.startswith("botocore.credentials") + ] + assert not credential_log_records, ( + f"Unexpected credential resolution: {[r.getMessage() for r in credential_log_records]}" + ) + + @patch.dict( + os.environ, + { + "UIPATH_URL": "https://example.com", + "UIPATH_ORGANIZATION_ID": "org", + "UIPATH_TENANT_ID": "tenant", + "UIPATH_ACCESS_TOKEN": "token", + }, + ) + def test_uipath_chat_bedrock_init_does_not_trigger_credential_resolution( + self, caplog + ): + with caplog.at_level(logging.DEBUG, logger="botocore"): + UiPathChatBedrock() + assert caplog.records credential_log_records = [ r for r in caplog.records if r.name.startswith("botocore.credentials") @@ -32,7 +95,6 @@ def test_client_creation_does_not_trigger_credential_resolution(self, caplog): assert not credential_log_records, ( f"Unexpected credential resolution: {[r.getMessage() for r in credential_log_records]}" ) - assert client._request_signer._signature_version == botocore.UNSIGNED class TestConvertFileBlocksToAnthropicDocuments: diff --git a/uv.lock b/uv.lock index 466278460..bcc998f38 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.23" +version = "0.8.24" source = { editable = "." } dependencies = [ { name = "httpx" }, From 6a078d9c5c29634ba83f3ff64ac8e74a082882a2 Mon Sep 17 00:00:00 2001 From: Valentina Bojan Date: Tue, 17 Mar 2026 12:53:21 +0200 Subject: [PATCH 12/33] feat: add User Prompt Attacks guardrail mapping (#697) Co-authored-by: Valentina Bojan Co-authored-by: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 +- .../agent/react/guardrails/guardrails_subgraph.py | 1 + uv.lock | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b7cbe8857..b4668e08b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.24" +version = "0.8.25" 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/react/guardrails/guardrails_subgraph.py b/src/uipath_langchain/agent/react/guardrails/guardrails_subgraph.py index 1d56547ba..658ceb74c 100644 --- a/src/uipath_langchain/agent/react/guardrails/guardrails_subgraph.py +++ b/src/uipath_langchain/agent/react/guardrails/guardrails_subgraph.py @@ -35,6 +35,7 @@ "pii_detection": {ExecutionStage.PRE_EXECUTION, ExecutionStage.POST_EXECUTION}, "harmful_content": {ExecutionStage.PRE_EXECUTION, ExecutionStage.POST_EXECUTION}, "intellectual_property": {ExecutionStage.POST_EXECUTION}, + "user_prompt_attacks": {ExecutionStage.PRE_EXECUTION}, } diff --git a/uv.lock b/uv.lock index bcc998f38..3665b66c7 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.24" +version = "0.8.25" source = { editable = "." } dependencies = [ { name = "httpx" }, From 544977f8968c0d233243f747f138e65e00c62e81 Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Tue, 17 Mar 2026 05:25:05 -0700 Subject: [PATCH 13/33] feat: Interrupt for low coded CAS agents [JAR-9208] (#641) --- .../durable_interrupt/__init__.py | 5 +- .../durable_interrupt/decorator.py | 0 .../durable_interrupt/skip_interrupt.py | 0 .../agent/tools/context_tool.py | 2 +- .../agent/tools/escalation_tool.py | 2 +- .../internal_tools/batch_transform_tool.py | 8 +- .../tools/internal_tools/deeprag_tool.py | 8 +- .../agent/tools/ixp_escalation_tool.py | 2 +- .../agent/tools/process_tool.py | 2 +- .../agent/tools/tool_factory.py | 11 + src/uipath_langchain/agent/tools/tool_node.py | 32 ++- src/uipath_langchain/chat/hitl.py | 110 +++++++++- src/uipath_langchain/runtime/messages.py | 17 +- src/uipath_langchain/runtime/runtime.py | 16 ++ .../test_batch_transform_tool.py | 10 +- .../tools/internal_tools/test_deeprag_tool.py | 6 +- tests/agent/tools/test_context_tool.py | 18 +- tests/agent/tools/test_durable_interrupt.py | 6 +- tests/agent/tools/test_escalation_tool.py | 24 +-- tests/agent/tools/test_ixp_escalation_tool.py | 14 +- .../test_langgraph_interrupt_contract.py | 8 +- tests/agent/tools/test_process_tool.py | 16 +- tests/agent/tools/test_tool_node.py | 200 +++++++++++++++++- tests/chat/test_hitl.py | 187 ++++++++++++++++ tests/runtime/test_chat_message_mapper.py | 126 +++++++++++ 25 files changed, 743 insertions(+), 87 deletions(-) rename src/uipath_langchain/{agent/tools => _utils}/durable_interrupt/__init__.py (75%) rename src/uipath_langchain/{agent/tools => _utils}/durable_interrupt/decorator.py (100%) rename src/uipath_langchain/{agent/tools => _utils}/durable_interrupt/skip_interrupt.py (100%) create mode 100644 tests/chat/test_hitl.py diff --git a/src/uipath_langchain/agent/tools/durable_interrupt/__init__.py b/src/uipath_langchain/_utils/durable_interrupt/__init__.py similarity index 75% rename from src/uipath_langchain/agent/tools/durable_interrupt/__init__.py rename to src/uipath_langchain/_utils/durable_interrupt/__init__.py index c814b0be3..bd36440fb 100644 --- a/src/uipath_langchain/agent/tools/durable_interrupt/__init__.py +++ b/src/uipath_langchain/_utils/durable_interrupt/__init__.py @@ -1,6 +1,9 @@ """Durable interrupt package for side-effect-safe interrupt/resume in LangGraph.""" -from .decorator import _durable_state, durable_interrupt +from .decorator import ( + _durable_state, + durable_interrupt, +) from .skip_interrupt import SkipInterruptValue __all__ = [ diff --git a/src/uipath_langchain/agent/tools/durable_interrupt/decorator.py b/src/uipath_langchain/_utils/durable_interrupt/decorator.py similarity index 100% rename from src/uipath_langchain/agent/tools/durable_interrupt/decorator.py rename to src/uipath_langchain/_utils/durable_interrupt/decorator.py diff --git a/src/uipath_langchain/agent/tools/durable_interrupt/skip_interrupt.py b/src/uipath_langchain/_utils/durable_interrupt/skip_interrupt.py similarity index 100% rename from src/uipath_langchain/agent/tools/durable_interrupt/skip_interrupt.py rename to src/uipath_langchain/_utils/durable_interrupt/skip_interrupt.py diff --git a/src/uipath_langchain/agent/tools/context_tool.py b/src/uipath_langchain/agent/tools/context_tool.py index 51837c45c..95fd4961a 100644 --- a/src/uipath_langchain/agent/tools/context_tool.py +++ b/src/uipath_langchain/agent/tools/context_tool.py @@ -26,6 +26,7 @@ from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain._utils import get_execution_folder_path +from uipath_langchain._utils.durable_interrupt import durable_interrupt from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( create_model as create_model_from_schema, @@ -40,7 +41,6 @@ ) from uipath_langchain.retrievers import ContextGroundingRetriever -from .durable_interrupt import durable_interrupt from .structured_tool_with_argument_properties import ( StructuredToolWithArgumentProperties, ) diff --git a/src/uipath_langchain/agent/tools/escalation_tool.py b/src/uipath_langchain/agent/tools/escalation_tool.py index ead8fdef3..1410c2e68 100644 --- a/src/uipath_langchain/agent/tools/escalation_tool.py +++ b/src/uipath_langchain/agent/tools/escalation_tool.py @@ -21,6 +21,7 @@ from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain._utils import get_execution_folder_path +from uipath_langchain._utils.durable_interrupt import durable_interrupt from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model from uipath_langchain.agent.tools.static_args import ( handle_static_args, @@ -31,7 +32,6 @@ from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode from ..react.types import AgentGraphState -from .durable_interrupt import durable_interrupt from .tool_node import ToolWrapperReturnType from .utils import ( resolve_task_title, diff --git a/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py b/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py index 6dcb55add..0b61b30ba 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py @@ -26,13 +26,13 @@ ) from uipath.runtime.errors import UiPathErrorCategory -from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode -from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model -from uipath_langchain.agent.react.types import AgentGraphState -from uipath_langchain.agent.tools.durable_interrupt import ( +from uipath_langchain._utils.durable_interrupt import ( SkipInterruptValue, durable_interrupt, ) +from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model +from uipath_langchain.agent.react.types import AgentGraphState from uipath_langchain.agent.tools.internal_tools.schema_utils import ( BATCH_TRANSFORM_OUTPUT_SCHEMA, add_query_field_to_schema, diff --git a/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py b/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py index 9effde108..6537a38ed 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py @@ -22,13 +22,13 @@ ) from uipath.runtime.errors import UiPathErrorCategory -from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode -from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model -from uipath_langchain.agent.react.types import AgentGraphState -from uipath_langchain.agent.tools.durable_interrupt import ( +from uipath_langchain._utils.durable_interrupt import ( SkipInterruptValue, durable_interrupt, ) +from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model +from uipath_langchain.agent.react.types import AgentGraphState from uipath_langchain.agent.tools.internal_tools.schema_utils import ( add_query_field_to_schema, ) diff --git a/src/uipath_langchain/agent/tools/ixp_escalation_tool.py b/src/uipath_langchain/agent/tools/ixp_escalation_tool.py index 68be1339e..98d593d7d 100644 --- a/src/uipath_langchain/agent/tools/ixp_escalation_tool.py +++ b/src/uipath_langchain/agent/tools/ixp_escalation_tool.py @@ -18,6 +18,7 @@ ) from uipath.runtime.errors import UiPathErrorCategory +from uipath_langchain._utils.durable_interrupt import durable_interrupt from uipath_langchain.agent.react.types import AgentGraphState from uipath_langchain.agent.tools.tool_node import ( ToolWrapperMixin, @@ -25,7 +26,6 @@ ) from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode -from .durable_interrupt import durable_interrupt from .structured_tool_with_output_type import StructuredToolWithOutputType from .utils import ( resolve_task_title, diff --git a/src/uipath_langchain/agent/tools/process_tool.py b/src/uipath_langchain/agent/tools/process_tool.py index 7bf9e647a..233e1b060 100644 --- a/src/uipath_langchain/agent/tools/process_tool.py +++ b/src/uipath_langchain/agent/tools/process_tool.py @@ -13,6 +13,7 @@ from uipath.platform.orchestrator import JobState from uipath_langchain._utils import get_execution_folder_path +from uipath_langchain._utils.durable_interrupt import durable_interrupt from uipath_langchain.agent.react.job_attachments import get_job_attachments from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model from uipath_langchain.agent.react.types import AgentGraphState @@ -24,7 +25,6 @@ ToolWrapperReturnType, ) -from .durable_interrupt import durable_interrupt from .utils import sanitize_tool_name diff --git a/src/uipath_langchain/agent/tools/tool_factory.py b/src/uipath_langchain/agent/tools/tool_factory.py index 8a87fec87..8f7bc5ca3 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -16,6 +16,8 @@ LowCodeAgentDefinition, ) +from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION + from .context_tool import create_context_tool from .escalation_tool import create_escalation_tool from .extraction_tool import create_ixp_extraction_tool @@ -54,6 +56,15 @@ async def create_tools_from_resources( else: tools.append(tool) + if agent.is_conversational: + props = getattr(resource, "properties", None) + if props and getattr( + props, REQUIRE_CONVERSATIONAL_CONFIRMATION, False + ): + if tool.metadata is None: + tool.metadata = {} + tool.metadata[REQUIRE_CONVERSATIONAL_CONFIRMATION] = True + return tools diff --git a/src/uipath_langchain/agent/tools/tool_node.py b/src/uipath_langchain/agent/tools/tool_node.py index eb187ebab..0bcfe5a2c 100644 --- a/src/uipath_langchain/agent/tools/tool_node.py +++ b/src/uipath_langchain/agent/tools/tool_node.py @@ -22,6 +22,7 @@ extract_current_tool_call_index, find_latest_ai_message, ) +from uipath_langchain.chat.hitl import request_conversational_tool_confirmation # the type safety can be improved with generics ToolWrapperReturnType = dict[str, Any] | Command[Any] | None @@ -80,6 +81,15 @@ def _func(self, state: AgentGraphState) -> OutputType: if call is None: return None + # prompt user for approval if tool requires confirmation + conversational_confirmation = request_conversational_tool_confirmation( + call, self.tool + ) + if conversational_confirmation: + if conversational_confirmation.cancelled: + # tool confirmation rejected + return self._process_result(call, conversational_confirmation.cancelled) + try: if self.wrapper: inputs = self._prepare_wrapper_inputs( @@ -88,7 +98,11 @@ def _func(self, state: AgentGraphState) -> OutputType: result = self.wrapper(*inputs) else: result = self.tool.invoke(call) - return self._process_result(call, result) + output = self._process_result(call, result) + if conversational_confirmation: + # HITL approved - apply confirmation metadata to tool result message + conversational_confirmation.annotate_result(output) + return output except GraphBubbleUp: # LangGraph uses exceptions for interrupt control flow — re-raise so # handle_tool_errors doesn't swallow expected interrupts as errors. @@ -104,15 +118,29 @@ async def _afunc(self, state: AgentGraphState) -> OutputType: if call is None: return None + # prompt user for approval if tool requires confirmation + conversational_confirmation = request_conversational_tool_confirmation( + call, self.tool + ) + if conversational_confirmation: + if conversational_confirmation.cancelled: + # tool confirmation rejected + return self._process_result(call, conversational_confirmation.cancelled) + try: if self.awrapper: inputs = self._prepare_wrapper_inputs( self.awrapper, self.tool, call, state ) + result = await self.awrapper(*inputs) else: result = await self.tool.ainvoke(call) - return self._process_result(call, result) + output = self._process_result(call, result) + if conversational_confirmation: + # HITL approved - apply confirmation metadata to tool result message + conversational_confirmation.annotate_result(output) + return output except GraphBubbleUp: # LangGraph uses exceptions for interrupt control flow — re-raise so # handle_tool_errors doesn't swallow expected interrupts as errors. diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index 625fc9a63..228d1b365 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -1,16 +1,66 @@ import functools import inspect +import json from inspect import Parameter -from typing import Annotated, Any, Callable +from typing import Annotated, Any, Callable, NamedTuple +from langchain_core.messages.tool import ToolCall, ToolMessage from langchain_core.tools import BaseTool, InjectedToolCallId from langchain_core.tools import tool as langchain_tool -from langgraph.types import interrupt from uipath.core.chat import ( UiPathConversationToolCallConfirmationValue, ) -_CANCELLED_MESSAGE = "Cancelled by user" +from uipath_langchain._utils.durable_interrupt import durable_interrupt + +CANCELLED_MESSAGE = "Cancelled by user" + +CONVERSATIONAL_APPROVED_TOOL_ARGS = "conversational_approved_tool_args" +REQUIRE_CONVERSATIONAL_CONFIRMATION = "require_conversational_confirmation" + + +class ConfirmationResult(NamedTuple): + """Result of a tool confirmation check.""" + + cancelled: ToolMessage | None # ToolMessage if cancelled, None if approved + args_modified: bool + approved_args: dict[str, Any] | None = None + + def annotate_result(self, output: dict[str, Any] | Any) -> None: + """Apply confirmation metadata to a tool result message.""" + msg = None + if isinstance(output, dict): + messages = output.get("messages") + if messages: + msg = messages[0] + else: + # Tools with @durable_interrupt return a Command whose messages + # are nested under output.update["messages"]. + update = getattr(output, "update", None) + if isinstance(update, dict): + messages = update.get("messages") + if messages: + msg = messages[0] + if msg is None: + return + if self.approved_args is not None: + msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = ( + self.approved_args + ) + if self.args_modified: + try: + result_value = json.loads(msg.content) + except (json.JSONDecodeError, TypeError): + result_value = msg.content + msg.content = json.dumps( + { + "meta": { + "args_modified_by_user": True, + "executed_args": self.approved_args, + }, + "result": result_value, + } + ) def _patch_span_input(approved_args: dict[str, Any]) -> None: @@ -53,7 +103,7 @@ def _patch_span_input(approved_args: dict[str, Any]) -> None: pass -def _request_approval( +def request_approval( tool_args: dict[str, Any], tool: BaseTool, ) -> dict[str, Any] | None: @@ -70,14 +120,16 @@ def _request_approval( if tool_call_schema is not None: input_schema = tool_call_schema.model_json_schema() - response = interrupt( - UiPathConversationToolCallConfirmationValue( + @durable_interrupt + def ask_confirmation(): + return UiPathConversationToolCallConfirmationValue( tool_call_id=tool_call_id, tool_name=tool.name, input_schema=input_schema, input_value=tool_args, ) - ) + + response = ask_confirmation() # The resume payload from CAS has shape: # {"type": "uipath_cas_tool_call_confirmation", @@ -89,9 +141,46 @@ def _request_approval( if not confirmation.get("approved", True): return None - return confirmation.get("input") or tool_args + return ( + confirmation.get("input") + if confirmation.get("input") is not None + else tool_args + ) + +# for conversational low code agents +def request_conversational_tool_confirmation( + call: ToolCall, tool: BaseTool +) -> ConfirmationResult | None: + """Check whether a tool requires user confirmation and request approval""" + if not (tool.metadata and tool.metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION)): + return None + original_args = call["args"] + approved_args = request_approval( + {**original_args, "tool_call_id": call["id"]}, tool + ) + if approved_args is None: + cancelled_msg = ToolMessage( + content=json.dumps({"meta": CANCELLED_MESSAGE}), + name=call["name"], + tool_call_id=call["id"], + ) + cancelled_msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = ( + original_args + ) + return ConfirmationResult(cancelled=cancelled_msg, args_modified=False) + + # Mutate call args so the tool executes with the approved values + call["args"] = approved_args + return ConfirmationResult( + cancelled=None, + args_modified=approved_args != original_args, + approved_args=approved_args, + ) + + +# for conversational coded agents def requires_approval( func: Callable[..., Any] | None = None, *, @@ -107,9 +196,10 @@ def decorator(fn: Callable[..., Any]) -> BaseTool: # wrap the tool/function @functools.wraps(fn) def wrapper(**tool_args: Any) -> Any: - approved_args = _request_approval(tool_args, _created_tool[0]) + approved_args = request_approval(tool_args, _created_tool[0]) if approved_args is None: - return _CANCELLED_MESSAGE + return json.dumps({"meta": CANCELLED_MESSAGE}) + _patch_span_input(approved_args) return fn(**approved_args) diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index 53712e912..43aba1626 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -58,6 +58,7 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None """Initialize the mapper with empty state.""" self.runtime_id = runtime_id self.storage = storage + self.tool_names_requiring_confirmation: set[str] = set() self.current_message: AIMessageChunk self.seen_message_ids: set[str] = set() self._storage_lock = asyncio.Lock() @@ -389,11 +390,17 @@ async def map_current_message_to_start_tool_call_events(self): tool_call_id_to_message_id_map[tool_call_id] = ( self.current_message.id ) - events.append( - self.map_tool_call_to_tool_call_start_event( - self.current_message.id, tool_call + + # if tool requires confirmation, we skip start tool call + if ( + tool_call["name"] + not in self.tool_names_requiring_confirmation + ): + events.append( + self.map_tool_call_to_tool_call_start_event( + self.current_message.id, tool_call + ) ) - ) if self.storage is not None: await self.storage.set_value( @@ -665,7 +672,7 @@ def _map_langchain_ai_message_to_uipath_message_data( role="assistant", content_parts=content_parts, tool_calls=uipath_tool_calls, - interrupts=[], # TODO: Interrupts + interrupts=[], ) diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index 228a5cdb9..feb327018 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -29,6 +29,7 @@ ) from uipath.runtime.schema import UiPathRuntimeSchema +from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError from uipath_langchain.runtime.messages import UiPathChatMessagesMapper from uipath_langchain.runtime.schema import get_entrypoints_schema, get_graph_schema @@ -64,6 +65,9 @@ def __init__( self.entrypoint: str | None = entrypoint self.callbacks: list[BaseCallbackHandler] = callbacks or [] self.chat = UiPathChatMessagesMapper(self.runtime_id, storage) + self.chat.tool_names_requiring_confirmation = ( + self._get_tool_names_requiring_confirmation() + ) self._middleware_node_names: set[str] = self._detect_middleware_nodes() async def execute( @@ -486,6 +490,18 @@ def _detect_middleware_nodes(self) -> set[str]: return middleware_nodes + def _get_tool_names_requiring_confirmation(self) -> set[str]: + names: set[str] = set() + for node_name, node_spec in self.graph.nodes.items(): + # langgraph's processing node.bound -> runnable.tool -> baseTool (if tool node) + tool = getattr(getattr(node_spec, "bound", None), "tool", None) + if tool is None: + continue + metadata = getattr(tool, "metadata", None) or {} + if metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION): + names.add(getattr(tool, "name", node_name)) + return names + def _is_middleware_node(self, node_name: str) -> bool: """Check if a node name represents a middleware node.""" return node_name in self._middleware_node_names diff --git a/tests/agent/tools/internal_tools/test_batch_transform_tool.py b/tests/agent/tools/internal_tools/test_batch_transform_tool.py index 5831913f0..6b3567868 100644 --- a/tests/agent/tools/internal_tools/test_batch_transform_tool.py +++ b/tests/agent/tools/internal_tools/test_batch_transform_tool.py @@ -150,7 +150,7 @@ def resource_config_dynamic(self, batch_transform_settings_dynamic_query): "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPathConfig" ) @patch("uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.mockable", lambda **kwargs: lambda f: f, @@ -242,7 +242,7 @@ async def test_create_batch_transform_tool_static_query_index_ready( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPathConfig" ) @patch("uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.mockable", lambda **kwargs: lambda f: f, @@ -323,7 +323,7 @@ async def test_create_batch_transform_tool_static_query_wait_for_ingestion( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPathConfig" ) @patch("uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.mockable", lambda **kwargs: lambda f: f, @@ -395,7 +395,7 @@ async def test_create_batch_transform_tool_dynamic_query( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPathConfig" ) @patch("uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.mockable", lambda **kwargs: lambda f: f, @@ -475,7 +475,7 @@ async def test_create_batch_transform_tool_default_destination_path( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPathConfig" ) @patch("uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.mockable", lambda **kwargs: lambda f: f, diff --git a/tests/agent/tools/internal_tools/test_deeprag_tool.py b/tests/agent/tools/internal_tools/test_deeprag_tool.py index 3934bb73e..1d2a9d2ef 100644 --- a/tests/agent/tools/internal_tools/test_deeprag_tool.py +++ b/tests/agent/tools/internal_tools/test_deeprag_tool.py @@ -122,7 +122,7 @@ def resource_config_dynamic(self, deeprag_settings_dynamic_query): "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_wrapper" ) @patch("uipath_langchain.agent.tools.internal_tools.deeprag_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.deeprag_tool.mockable", lambda **kwargs: lambda f: f, @@ -192,7 +192,7 @@ async def test_create_deeprag_tool_static_query_index_ready( "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_wrapper" ) @patch("uipath_langchain.agent.tools.internal_tools.deeprag_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.deeprag_tool.mockable", lambda **kwargs: lambda f: f, @@ -257,7 +257,7 @@ async def test_create_deeprag_tool_static_query_wait_for_ingestion( "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_wrapper" ) @patch("uipath_langchain.agent.tools.internal_tools.deeprag_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.deeprag_tool.mockable", lambda **kwargs: lambda f: f, diff --git a/tests/agent/tools/test_context_tool.py b/tests/agent/tools/test_context_tool.py index eaaabf00d..da867a9d6 100644 --- a/tests/agent/tools/test_context_tool.py +++ b/tests/agent/tools/test_context_tool.py @@ -218,7 +218,7 @@ async def test_tool_with_different_citation_modes(self, base_resource_config): tool = handle_deep_rag("test_tool", resource) with patch( - "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" ) as mock_interrupt: mock_interrupt.return_value = {"mocked": "response"} assert tool.coroutine is not None @@ -240,7 +240,7 @@ async def test_unique_task_names_on_multiple_invocations( task_names = [] with patch( - "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" ) as mock_interrupt: mock_interrupt.return_value = {"mocked": "response"} @@ -301,7 +301,7 @@ async def test_dynamic_query_uses_provided_query(self, base_resource_config): tool = handle_deep_rag("test_tool", resource) with patch( - "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" ) as mock_interrupt: mock_interrupt.return_value = {"mocked": "response"} assert tool.coroutine is not None @@ -322,7 +322,7 @@ async def test_deep_rag_uses_execution_folder_path(self, base_resource_config): tool = handle_deep_rag("test_tool", resource) with patch( - "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" ) as mock_interrupt: mock_interrupt.return_value = {"mocked": "response"} assert tool.coroutine is not None @@ -693,7 +693,7 @@ async def test_static_query_batch_transform_uses_predefined_query( mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id-1") with ( patch( - "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" ) as mock_interrupt, patch( "uipath_langchain.agent.tools.context_tool.UiPath", @@ -741,7 +741,7 @@ async def test_dynamic_query_batch_transform_uses_provided_query(self): mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id-2") with ( patch( - "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" ) as mock_interrupt, patch( "uipath_langchain.agent.tools.context_tool.UiPath", @@ -769,7 +769,7 @@ async def test_static_query_batch_transform_uses_default_destination_path( mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id-3") with ( patch( - "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" ) as mock_interrupt, patch( "uipath_langchain.agent.tools.context_tool.UiPath", @@ -818,7 +818,7 @@ async def test_dynamic_query_batch_transform_uses_default_destination_path(self) mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id-4") with ( patch( - "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" ) as mock_interrupt, patch( "uipath_langchain.agent.tools.context_tool.UiPath", @@ -846,7 +846,7 @@ async def test_batch_transform_uses_execution_folder_path( mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id") with ( patch( - "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" + "uipath_langchain._utils.durable_interrupt.decorator.interrupt" ) as mock_interrupt, patch( "uipath_langchain.agent.tools.context_tool.UiPath", diff --git a/tests/agent/tools/test_durable_interrupt.py b/tests/agent/tools/test_durable_interrupt.py index 200c419d9..6f6825561 100644 --- a/tests/agent/tools/test_durable_interrupt.py +++ b/tests/agent/tools/test_durable_interrupt.py @@ -7,7 +7,7 @@ import pytest from langgraph._internal._constants import CONFIG_KEY_SCRATCHPAD -from uipath_langchain.agent.tools.durable_interrupt import ( +from uipath_langchain._utils.durable_interrupt import ( _durable_state, durable_interrupt, ) @@ -27,8 +27,8 @@ def _make_config(scratchpad: FakeScratchpad | None = None) -> dict[str, Any]: return {"configurable": {CONFIG_KEY_SCRATCHPAD: scratchpad}} -PATCH_GET_CONFIG = "uipath_langchain.agent.tools.durable_interrupt.decorator.get_config" -PATCH_INTERRUPT = "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" +PATCH_GET_CONFIG = "uipath_langchain._utils.durable_interrupt.decorator.get_config" +PATCH_INTERRUPT = "uipath_langchain._utils.durable_interrupt.decorator.interrupt" @pytest.fixture(autouse=True) diff --git a/tests/agent/tools/test_escalation_tool.py b/tests/agent/tools/test_escalation_tool.py index 67c843ecc..8106b56f1 100644 --- a/tests/agent/tools/test_escalation_tool.py +++ b/tests/agent/tools/test_escalation_tool.py @@ -288,7 +288,7 @@ async def test_escalation_tool_metadata_has_channel_type(self, escalation_resour @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_metadata_has_recipient( self, mock_interrupt, mock_uipath_class, escalation_resource ): @@ -317,7 +317,7 @@ async def test_escalation_tool_metadata_has_recipient( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_metadata_recipient_none_when_no_recipients( self, mock_interrupt, mock_uipath_class, escalation_resource_no_recipient ): @@ -342,7 +342,7 @@ async def test_escalation_tool_metadata_recipient_none_when_no_recipients( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_string_task_title( self, mock_interrupt, mock_uipath_class ): @@ -392,7 +392,7 @@ async def test_escalation_tool_with_string_task_title( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_text_builder_task_title( self, mock_interrupt, mock_uipath_class ): @@ -450,7 +450,7 @@ async def test_escalation_tool_with_text_builder_task_title( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_empty_task_title_defaults_to_escalation_task( self, mock_interrupt, mock_uipath_class ): @@ -548,7 +548,7 @@ async def test_escalation_tool_output_schema_has_action_field( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_result_validation( self, mock_interrupt, mock_uipath_class, escalation_resource ): @@ -576,7 +576,7 @@ async def test_escalation_tool_result_validation( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_extracts_action_from_result( self, mock_interrupt, mock_uipath_class, escalation_resource ): @@ -600,7 +600,7 @@ async def test_escalation_tool_extracts_action_from_result( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_raises_when_task_is_deleted( self, mock_interrupt, mock_uipath_class, escalation_resource ): @@ -625,7 +625,7 @@ async def test_escalation_tool_raises_when_task_is_deleted( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_dict_result_without_is_deleted_defaults_to_false( self, mock_interrupt, mock_uipath_class, escalation_resource ): @@ -650,7 +650,7 @@ async def test_escalation_tool_dict_result_without_is_deleted_defaults_to_false( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_outcome_mapping_end( self, mock_interrupt, mock_uipath_class ): @@ -830,7 +830,7 @@ def escalation_resource(self): @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_creates_task_then_interrupts_with_wait_escalation( self, mock_interrupt, mock_uipath_class, escalation_resource ): @@ -865,7 +865,7 @@ async def test_creates_task_then_interrupts_with_wait_escalation( @pytest.mark.asyncio @patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Test/Folder"}) @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_creates_task_with_execution_folder_path( self, mock_interrupt, mock_uipath_class, escalation_resource ): diff --git a/tests/agent/tools/test_ixp_escalation_tool.py b/tests/agent/tools/test_ixp_escalation_tool.py index 5e7dab19a..723f4aad4 100644 --- a/tests/agent/tools/test_ixp_escalation_tool.py +++ b/tests/agent/tools/test_ixp_escalation_tool.py @@ -187,7 +187,7 @@ def mock_state_without_extraction(self): @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_wrapper_retrieves_extraction_from_state( self, mock_interrupt, @@ -287,7 +287,7 @@ async def test_wrapper_looks_for_correct_ixp_tool_id( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_wrapper_raises_on_document_rejection( self, mock_interrupt, @@ -368,7 +368,7 @@ def mock_extraction_response(self): @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_tool_calls_interrupt_with_correct_params( self, mock_interrupt, @@ -409,7 +409,7 @@ async def test_tool_calls_interrupt_with_correct_params( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_tool_uses_default_action_title_when_not_provided( self, mock_interrupt, mock_uipath_cls, mock_extraction_response ): @@ -462,7 +462,7 @@ async def test_tool_uses_default_action_title_when_not_provided( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_tool_uses_default_priority_when_not_provided( self, mock_interrupt, mock_uipath_cls, mock_extraction_response ): @@ -515,7 +515,7 @@ async def test_tool_uses_default_priority_when_not_provided( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_tool_returns_data_projection_as_dict( self, mock_interrupt, @@ -543,7 +543,7 @@ async def test_tool_returns_data_projection_as_dict( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_tool_stores_validation_response_in_metadata( self, mock_interrupt, diff --git a/tests/agent/tools/test_langgraph_interrupt_contract.py b/tests/agent/tools/test_langgraph_interrupt_contract.py index 0231d818a..792309d9e 100644 --- a/tests/agent/tools/test_langgraph_interrupt_contract.py +++ b/tests/agent/tools/test_langgraph_interrupt_contract.py @@ -170,7 +170,7 @@ class TestDurableInterruptAlignment: def test_single_durable_interrupt_returns_resume_value(self) -> None: """On resume, durable_interrupt returns the resume value directly.""" - from uipath_langchain.agent.tools.durable_interrupt import ( + from uipath_langchain._utils.durable_interrupt import ( _durable_state, durable_interrupt, ) @@ -196,7 +196,7 @@ def create_job() -> dict[str, str]: def test_two_durable_interrupts_return_sequential_resume_values(self) -> None: """Two @durable_interrupt calls return resume values by index.""" - from uipath_langchain.agent.tools.durable_interrupt import ( + from uipath_langchain._utils.durable_interrupt import ( _durable_state, durable_interrupt, ) @@ -224,7 +224,7 @@ def task_b() -> str: def test_partial_resume_first_returns_value_second_raises(self) -> None: """One resume value: first returns it, second runs body and raises GraphInterrupt.""" - from uipath_langchain.agent.tools.durable_interrupt import ( + from uipath_langchain._utils.durable_interrupt import ( _durable_state, durable_interrupt, ) @@ -256,7 +256,7 @@ def task_b() -> str: async def test_async_durable_interrupt_returns_resume_value(self) -> None: """Async variant: durable_interrupt returns resume value directly.""" - from uipath_langchain.agent.tools.durable_interrupt import ( + from uipath_langchain._utils.durable_interrupt import ( _durable_state, durable_interrupt, ) diff --git a/tests/agent/tools/test_process_tool.py b/tests/agent/tools/test_process_tool.py index f701e2500..3f1f7642c 100644 --- a/tests/agent/tools/test_process_tool.py +++ b/tests/agent/tools/test_process_tool.py @@ -117,7 +117,7 @@ class TestProcessToolInvocation: @pytest.mark.asyncio @patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/MyFolder"}) - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_invoke_calls_processes_invoke_async( self, mock_uipath_class, mock_interrupt, process_resource @@ -150,7 +150,7 @@ async def test_invoke_calls_processes_invoke_async( ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_invoke_interrupts_with_wait_job( self, mock_uipath_class, mock_interrupt, process_resource @@ -181,7 +181,7 @@ async def test_invoke_interrupts_with_wait_job( @pytest.mark.asyncio @patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/DataFolder"}) - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_invoke_passes_input_arguments( self, mock_uipath_class, mock_interrupt, process_resource_with_inputs @@ -210,7 +210,7 @@ async def test_invoke_passes_input_arguments( assert call_kwargs["folder_path"] == "/Shared/DataFolder" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_invoke_returns_output_from_extract( self, mock_uipath_class, mock_interrupt, process_resource @@ -238,7 +238,7 @@ async def test_invoke_returns_output_from_extract( assert result == {"output_arg": "value123"} @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_invoke_returns_error_message_on_faulted_job( self, mock_uipath_class, mock_interrupt, process_resource @@ -270,7 +270,7 @@ class TestProcessToolSpanContext: """Test that _span_context is properly wired for tracing.""" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_span_context_parent_span_id_passed_to_invoke( self, mock_uipath_class, mock_interrupt, process_resource @@ -302,7 +302,7 @@ async def test_span_context_parent_span_id_passed_to_invoke( assert call_kwargs["parent_span_id"] == "span-abc-123" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_span_context_consumed_after_invoke( self, mock_uipath_class, mock_interrupt, process_resource @@ -332,7 +332,7 @@ async def test_span_context_consumed_after_invoke( assert "parent_span_id" not in tool.metadata["_span_context"] @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_span_context_defaults_to_none_when_empty( self, mock_uipath_class, mock_interrupt, process_resource diff --git a/tests/agent/tools/test_tool_node.py b/tests/agent/tools/test_tool_node.py index af3da38cb..870cedf18 100644 --- a/tests/agent/tools/test_tool_node.py +++ b/tests/agent/tools/test_tool_node.py @@ -1,6 +1,8 @@ """Tests for tool_node.py module.""" +import json from typing import Any, Dict +from unittest.mock import patch import pytest from langchain_core.messages import AIMessage, HumanMessage @@ -13,11 +15,16 @@ AgentRuntimeError, AgentRuntimeErrorCode, ) +from uipath_langchain.agent.react.types import AgentGraphState from uipath_langchain.agent.tools.tool_node import ( ToolWrapperMixin, UiPathToolNode, create_tool_node, ) +from uipath_langchain.chat.hitl import ( + CANCELLED_MESSAGE, + CONVERSATIONAL_APPROVED_TOOL_ARGS, +) class MockTool(BaseTool): @@ -66,10 +73,9 @@ class FilteredState(BaseModel): session_id: str = "test_session" -class MockState(BaseModel): +class MockState(AgentGraphState): """Mock state for testing.""" - messages: list[Any] = [] user_id: str = "test_user" session_id: str = "test_session" @@ -310,7 +316,7 @@ def test_tool_error_propagates_when_handle_errors_false(self, mock_state): node = UiPathToolNode(failing_tool, handle_tool_errors=False) with pytest.raises(ValueError) as exc_info: - node._func(state) # type: ignore[arg-type] + node._func(state) assert "Tool execution failed: test input" in str(exc_info.value) @@ -328,7 +334,7 @@ async def test_async_tool_error_propagates_when_handle_errors_false(self): node = UiPathToolNode(failing_tool, handle_tool_errors=False) with pytest.raises(ValueError) as exc_info: - await node._afunc(state) # type: ignore[arg-type] + await node._afunc(state) assert "Async tool execution failed: test input" in str(exc_info.value) @@ -345,7 +351,7 @@ def test_tool_error_captured_when_handle_errors_true(self): node = UiPathToolNode(failing_tool, handle_tool_errors=True) - result = node._func(state) # type: ignore[arg-type] + result = node._func(state) assert result is not None assert isinstance(result, dict) @@ -372,7 +378,7 @@ async def test_async_tool_error_captured_when_handle_errors_true(self): node = UiPathToolNode(failing_tool, handle_tool_errors=True) - result = await node._afunc(state) # type: ignore[arg-type] + result = await node._afunc(state) assert result is not None assert isinstance(result, dict) @@ -482,3 +488,185 @@ def test_create_tool_node_with_handle_errors_true(self): node = result[tool_name] assert isinstance(node, UiPathToolNode) assert node.handle_tool_errors is True + + +class TestToolNodeConfirmation: + """Tests for confirmation flow in UiPathToolNode._func / _afunc.""" + + @pytest.fixture + def confirmation_tool(self): + """Tool with require_conversational_confirmation metadata.""" + return MockTool(metadata={"require_conversational_confirmation": True}) + + @pytest.fixture + def confirmation_state(self): + tool_call = { + "name": "mock_tool", + "args": {"input_text": "test input"}, + "id": "test_call_id", + } + ai_message = AIMessage(content="Using tool", tool_calls=[tool_call]) + return MockState(messages=[ai_message]) + + def test_no_confirmation_without_metadata(self): + """Tool without metadata executes normally, no interrupt.""" + tool = MockTool() # no metadata + node = UiPathToolNode(tool) + tool_call = { + "name": "mock_tool", + "args": {"input_text": "hello"}, + "id": "call_1", + } + state = MockState(messages=[AIMessage(content="go", tool_calls=[tool_call])]) + + result = node._func(state) + + assert result is not None + assert isinstance(result, dict) + assert "Mock result: hello" in result["messages"][0].content + + @patch("uipath_langchain.chat.hitl.request_approval", return_value=None) + def test_cancelled_returns_cancelled_message( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Rejected confirmation returns CANCELLED_MESSAGE.""" + node = UiPathToolNode(confirmation_tool) + + result = node._func(confirmation_state) + + assert result is not None + assert isinstance(result, dict) + msg = result["messages"][0] + assert isinstance(msg, ToolMessage) + assert msg.content == json.dumps({"meta": CANCELLED_MESSAGE}) + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"input_text": "test input"}, + ) + def test_approved_same_args_no_meta( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Approved with same args → normal execution, no meta injected.""" + node = UiPathToolNode(confirmation_tool) + + result = node._func(confirmation_state) + + assert result is not None + assert isinstance(result, dict) + msg = result["messages"][0] + assert "args_modified_by_user" not in msg.content + assert "Mock result:" in msg.content + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"input_text": "edited"}, + ) + def test_approved_modified_args_injects_meta( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Approved with edited args → tool runs with new args, meta injected.""" + node = UiPathToolNode(confirmation_tool) + + result = node._func(confirmation_state) + + assert result is not None + assert isinstance(result, dict) + msg = result["messages"][0] + + assert isinstance(msg.content, str) + wrapped = json.loads(msg.content) + assert wrapped["meta"]["args_modified_by_user"] is True + assert wrapped["meta"]["executed_args"] == {"input_text": "edited"} + assert "Mock result: edited" in wrapped["result"] + + @patch("uipath_langchain.chat.hitl.request_approval", return_value=None) + async def test_async_cancelled( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Async path: rejected confirmation returns CANCELLED_MESSAGE.""" + node = UiPathToolNode(confirmation_tool) + + result = await node._afunc(confirmation_state) + + assert result is not None + assert isinstance(result, dict) + msg = result["messages"][0] + assert msg.content == json.dumps({"meta": CANCELLED_MESSAGE}) + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"input_text": "async edited"}, + ) + async def test_async_approved_modified_args( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Async path: approved with edited args → meta injected.""" + node = UiPathToolNode(confirmation_tool) + + result = await node._afunc(confirmation_state) + + assert result is not None + assert isinstance(result, dict) + msg = result["messages"][0] + + assert isinstance(msg.content, str) + wrapped = json.loads(msg.content) + assert wrapped["meta"]["args_modified_by_user"] is True + assert wrapped["meta"]["executed_args"] == {"input_text": "async edited"} + assert "Async mock result: async edited" in wrapped["result"] + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"input_text": "approved"}, + ) + def test_approved_attaches_approved_args_metadata( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Approved path attaches approved args in response_metadata.""" + node = UiPathToolNode(confirmation_tool) + + result = node._func(confirmation_state) + + assert result is not None + assert isinstance(result, dict) + msg = result["messages"][0] + assert CONVERSATIONAL_APPROVED_TOOL_ARGS in msg.response_metadata + assert msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] == { + "input_text": "approved" + } + + @patch("uipath_langchain.chat.hitl.request_approval", return_value=None) + def test_cancelled_attaches_original_args_metadata( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Cancelled path attaches original args in response_metadata.""" + node = UiPathToolNode(confirmation_tool) + + result = node._func(confirmation_state) + + assert result is not None + assert isinstance(result, dict) + msg = result["messages"][0] + assert CONVERSATIONAL_APPROVED_TOOL_ARGS in msg.response_metadata + assert msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] == { + "input_text": "test input" + } + + def test_no_confirmation_no_metadata(self): + """Non-confirmation tools don't get the approved args metadata.""" + tool = MockTool() # no confirmation metadata + node = UiPathToolNode(tool) + tool_call = { + "name": "mock_tool", + "args": {"input_text": "hello"}, + "id": "call_1", + } + state = MockState(messages=[AIMessage(content="go", tool_calls=[tool_call])]) + + result = node._func(state) + + assert result is not None + assert isinstance(result, dict) + msg = result["messages"][0] + assert CONVERSATIONAL_APPROVED_TOOL_ARGS not in msg.response_metadata diff --git a/tests/chat/test_hitl.py b/tests/chat/test_hitl.py new file mode 100644 index 000000000..5ef910324 --- /dev/null +++ b/tests/chat/test_hitl.py @@ -0,0 +1,187 @@ +"""Tests for hitl.py module.""" + +import json +from typing import Any +from unittest.mock import patch + +from langchain_core.messages.tool import ToolCall, ToolMessage +from langchain_core.tools import BaseTool + +from uipath_langchain.chat.hitl import ( + CANCELLED_MESSAGE, + CONVERSATIONAL_APPROVED_TOOL_ARGS, + ConfirmationResult, + request_approval, + request_conversational_tool_confirmation, +) + + +class MockTool(BaseTool): + name: str = "mock_tool" + description: str = "A mock tool" + + def _run(self) -> str: + return "" + + +def _make_call(args: dict[str, Any] | None = None) -> ToolCall: + return ToolCall(name="mock_tool", args=args or {"query": "test"}, id="call_1") + + +class TestCheckToolConfirmation: + """Tests for request_conversational_tool_confirmation.""" + + def test_returns_none_when_no_metadata(self): + """No metadata → no confirmation needed.""" + tool = MockTool() + call = _make_call() + assert request_conversational_tool_confirmation(call, tool) is None + + def test_returns_none_when_flag_not_set(self): + """Metadata exists but flag is missing → no confirmation needed.""" + tool = MockTool(metadata={"other_key": True}) + call = _make_call() + assert request_conversational_tool_confirmation(call, tool) is None + + def test_returns_none_when_flag_false(self): + """Flag explicitly False → no confirmation needed.""" + tool = MockTool(metadata={"require_conversational_confirmation": False}) + call = _make_call() + assert request_conversational_tool_confirmation(call, tool) is None + + @patch("uipath_langchain.chat.hitl.request_approval", return_value=None) + def test_cancelled_returns_tool_message(self, mock_approval): + """User rejects → ConfirmationResult with cancelled ToolMessage and metadata.""" + tool = MockTool(metadata={"require_conversational_confirmation": True}) + call = _make_call() + + result = request_conversational_tool_confirmation(call, tool) + + assert result is not None + assert isinstance(result, ConfirmationResult) + assert result.cancelled is not None + assert isinstance(result.cancelled, ToolMessage) + assert result.cancelled.content == json.dumps({"meta": CANCELLED_MESSAGE}) + assert result.cancelled.name == "mock_tool" + assert result.cancelled.tool_call_id == "call_1" + assert result.args_modified is False + assert result.cancelled.response_metadata[ + CONVERSATIONAL_APPROVED_TOOL_ARGS + ] == {"query": "test"} + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"query": "test"}, + ) + def test_approved_same_args(self, mock_approval): + """User approves without editing → cancelled=None, args_modified=False.""" + tool = MockTool(metadata={"require_conversational_confirmation": True}) + call = _make_call({"query": "test"}) + + result = request_conversational_tool_confirmation(call, tool) + + assert result is not None + assert result.cancelled is None + assert result.args_modified is False + assert result.approved_args == {"query": "test"} + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"query": "edited"}, + ) + def test_approved_modified_args(self, mock_approval): + """User edits args → cancelled=None, args_modified=True, call updated.""" + tool = MockTool(metadata={"require_conversational_confirmation": True}) + call = _make_call({"query": "original"}) + + result = request_conversational_tool_confirmation(call, tool) + + assert result is not None + assert result.cancelled is None + assert result.args_modified is True + assert result.approved_args == {"query": "edited"} + assert call["args"] == {"query": "edited"} + + +class TestAnnotateResult: + """Tests for ConfirmationResult.annotate_result.""" + + def test_annotate_sets_metadata(self): + """annotate_result sets approved_args on response_metadata.""" + confirmation = ConfirmationResult( + cancelled=None, args_modified=False, approved_args={"query": "test"} + ) + msg = ToolMessage(content="result", tool_call_id="call_1") + output = {"messages": [msg]} + + confirmation.annotate_result(output) + + assert msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] == { + "query": "test" + } + assert msg.content == "result" + + def test_annotate_wraps_content_when_modified(self): + """annotate_result wraps content with structured meta when args were modified.""" + confirmation = ConfirmationResult( + cancelled=None, args_modified=True, approved_args={"query": "edited"} + ) + msg = ToolMessage(content="result", tool_call_id="call_1") + output = {"messages": [msg]} + + confirmation.annotate_result(output) + + assert msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] == { + "query": "edited" + } + import json + + assert isinstance(msg.content, str) + wrapped = json.loads(msg.content) + assert wrapped["meta"]["args_modified_by_user"] is True + assert wrapped["meta"]["executed_args"] == {"query": "edited"} + assert wrapped["result"] == "result" + + +class TestRequestApprovalTruthiness: + """Tests for the truthiness fix in request_approval.""" + + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") + def test_empty_dict_input_preserved(self, mock_interrupt): + """Empty dict from user edits should not be replaced by original args.""" + mock_interrupt.return_value = {"value": {"approved": True, "input": {}}} + tool = MockTool() + result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) + assert result == {} + + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") + def test_empty_list_input_preserved(self, mock_interrupt): + """Empty list from user edits should not be replaced by original args.""" + mock_interrupt.return_value = {"value": {"approved": True, "input": []}} + tool = MockTool() + result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) + assert result == [] + + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") + def test_none_input_falls_back_to_original(self, mock_interrupt): + """None input should fall back to original tool_args.""" + mock_interrupt.return_value = {"value": {"approved": True, "input": None}} + tool = MockTool() + result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) + assert result == {"query": "test"} + + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") + def test_missing_input_falls_back_to_original(self, mock_interrupt): + """Missing input key should fall back to original tool_args.""" + mock_interrupt.return_value = {"value": {"approved": True}} + tool = MockTool() + result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) + assert result == {"query": "test"} + + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") + def test_rejected_returns_none(self, mock_interrupt): + """Rejected approval returns None.""" + mock_interrupt.return_value = {"value": {"approved": False}} + tool = MockTool() + result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) + assert result is None diff --git a/tests/runtime/test_chat_message_mapper.py b/tests/runtime/test_chat_message_mapper.py index 3eabe5e66..35db6a912 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -1718,3 +1718,129 @@ def test_ai_message_with_media_citation(self): assert isinstance(source, UiPathConversationCitationSourceMedia) assert source.download_url == "https://r.com" assert source.page_number == "3" + + +class TestConfirmationToolDeferral: + """Tests for deferring startToolCall events for confirmation tools.""" + + @pytest.mark.asyncio + async def test_start_tool_call_skipped_for_confirmation_tool(self): + """AIMessageChunk with confirmation tool should NOT emit startToolCall.""" + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + mapper.tool_names_requiring_confirmation = {"confirm_tool"} + + # First chunk starts the message with a confirmation tool call + first_chunk = AIMessageChunk( + content="", + id="msg-1", + tool_calls=[{"id": "tc-1", "name": "confirm_tool", "args": {"x": 1}}], + ) + await mapper.map_event(first_chunk) + + # Last chunk triggers tool call start events + last_chunk = AIMessageChunk(content="", id="msg-1") + object.__setattr__(last_chunk, "chunk_position", "last") + result = await mapper.map_event(last_chunk) + + assert result is not None + tool_start_events = [ + e + for e in result + if e.tool_call is not None and e.tool_call.start is not None + ] + assert len(tool_start_events) == 0 + + @pytest.mark.asyncio + async def test_start_tool_call_emitted_for_non_confirmation_tool(self): + """Normal tools still emit startToolCall even when confirmation set is populated.""" + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + mapper.tool_names_requiring_confirmation = {"other_tool"} + + first_chunk = AIMessageChunk( + content="", + id="msg-2", + tool_calls=[{"id": "tc-2", "name": "normal_tool", "args": {}}], + ) + await mapper.map_event(first_chunk) + + last_chunk = AIMessageChunk(content="", id="msg-2") + object.__setattr__(last_chunk, "chunk_position", "last") + result = await mapper.map_event(last_chunk) + + assert result is not None + tool_start_events = [ + e + for e in result + if e.tool_call is not None and e.tool_call.start is not None + ] + assert len(tool_start_events) >= 1 + assert tool_start_events[0].tool_call is not None + assert tool_start_events[0].tool_call.start is not None + assert tool_start_events[0].tool_call.start.tool_name == "normal_tool" + + @pytest.mark.asyncio + async def test_confirmation_tool_message_emits_only_end(self): + """ToolMessage for a confirmation tool should only emit endToolCall + messageEnd. + + startToolCall is now emitted by the bridge on HITL approval, not here. + """ + storage = create_mock_storage() + storage.get_value.return_value = {"tc-3": "msg-3"} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + mapper.tool_names_requiring_confirmation = {"confirm_tool"} + + tool_msg = ToolMessage( + content='{"result": "ok"}', + tool_call_id="tc-3", + name="confirm_tool", + ) + + result = await mapper.map_event(tool_msg) + + assert result is not None + # Should have: endToolCall, messageEnd (no startToolCall) + assert len(result) == 2 + + # First event: endToolCall + end_event = result[0] + assert end_event.tool_call is not None + assert end_event.tool_call.end is not None + + # Second event: messageEnd + assert result[1].end is not None + + @pytest.mark.asyncio + async def test_mixed_tools_only_confirmation_deferred(self): + """Mixed tools in one AIMessage: only confirmation tool's startToolCall is deferred.""" + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + mapper.tool_names_requiring_confirmation = {"confirm_tool"} + + first_chunk = AIMessageChunk( + content="", + id="msg-4", + tool_calls=[ + {"id": "tc-normal", "name": "normal_tool", "args": {"a": 1}}, + {"id": "tc-confirm", "name": "confirm_tool", "args": {"b": 2}}, + ], + ) + await mapper.map_event(first_chunk) + + last_chunk = AIMessageChunk(content="", id="msg-4") + object.__setattr__(last_chunk, "chunk_position", "last") + result = await mapper.map_event(last_chunk) + + assert result is not None + tool_start_names = [ + e.tool_call.start.tool_name + for e in result + if e.tool_call is not None and e.tool_call.start is not None + ] + # normal_tool should have startToolCall, confirm_tool should NOT + assert "normal_tool" in tool_start_names + assert "confirm_tool" not in tool_start_names From f8b1b58a3cf196815158fdad857df1b162f69af1 Mon Sep 17 00:00:00 2001 From: Ionut Mihalache <67947900+ionut-mihalache-uipath@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:00:28 +0200 Subject: [PATCH 14/33] fix: typo in OpenAiResponses name (#698) --- src/uipath_langchain/chat/types.py | 2 +- tests/chat/test_chat_model_factory.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/uipath_langchain/chat/types.py b/src/uipath_langchain/chat/types.py index b57b8f615..60cedfc78 100644 --- a/src/uipath_langchain/chat/types.py +++ b/src/uipath_langchain/chat/types.py @@ -12,7 +12,7 @@ class LLMProvider(StrEnum): class APIFlavor(StrEnum): """API flavor for LLM communication.""" - OPENAI_RESPONSES = "OpenAIResponses" + OPENAI_RESPONSES = "OpenAiResponses" OPENAI_COMPLETIONS = "OpenAiChatCompletions" AWS_BEDROCK_CONVERSE = "AwsBedrockConverse" AWS_BEDROCK_INVOKE = "AwsBedrockInvoke" diff --git a/tests/chat/test_chat_model_factory.py b/tests/chat/test_chat_model_factory.py index 16e868905..183e35be6 100644 --- a/tests/chat/test_chat_model_factory.py +++ b/tests/chat/test_chat_model_factory.py @@ -20,7 +20,7 @@ def test_both_vendor_and_api_flavor_present_openai(self): model = { "modelName": "gpt-4", "vendor": "OpenAi", - "apiFlavor": "OpenAIResponses", + "apiFlavor": "OpenAiResponses", } vendor, api_flavor = _compute_vendor_and_api_flavor(model) @@ -57,11 +57,11 @@ def test_both_vendor_and_api_flavor_present_vertex(self): # ========== Only api_flavor present (vendor is null) ========== def test_only_api_flavor_openai_responses(self): - """Test deriving vendor from OpenAIResponses api_flavor.""" + """Test deriving vendor from OpenAiResponses api_flavor.""" model = { "modelName": "gpt-4", "vendor": None, - "apiFlavor": "OpenAIResponses", + "apiFlavor": "OpenAiResponses", } vendor, api_flavor = _compute_vendor_and_api_flavor(model) @@ -138,7 +138,7 @@ def test_only_api_flavor_vendor_missing_key(self): """Test when vendor key is missing entirely (not just None).""" model = { "modelName": "gpt-4", - "apiFlavor": "OpenAIResponses", + "apiFlavor": "OpenAiResponses", } vendor, api_flavor = _compute_vendor_and_api_flavor(model) @@ -286,7 +286,7 @@ def test_error_unknown_vendor_with_api_flavor(self): model = { "modelName": "some-model", "vendor": "UnknownVendor", - "apiFlavor": "OpenAIResponses", + "apiFlavor": "OpenAiResponses", } with pytest.raises(ValueError) as exc_info: From b11448ac04f8a811048a3696922f6849ec174fcc Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:34:04 -0700 Subject: [PATCH 15/33] fix: escape apostrophes for parsing and rendering [JAR-9386] (#704) --- pyproject.toml | 2 +- src/uipath_langchain/runtime/_citations.py | 8 +-- tests/runtime/test_citations.py | 57 ++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b4668e08b..fc5ca1893 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.25" +version = "0.8.26" 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/runtime/_citations.py b/src/uipath_langchain/runtime/_citations.py index 37b89c89a..08f4112dc 100644 --- a/src/uipath_langchain/runtime/_citations.py +++ b/src/uipath_langchain/runtime/_citations.py @@ -22,8 +22,8 @@ logger = logging.getLogger(__name__) -_TAG_RE = re.compile(r'') -_ATTR_RE = re.compile(r'([a-z_]+)="([^"]*)"') +_TAG_RE = re.compile(r'') +_ATTR_RE = re.compile(r'([a-z_]+)="((?:[^"\\]|\\.)*)"') @dataclass(frozen=True) # frozen to make hashable / de-dupe sources @@ -45,7 +45,9 @@ def _parse_citations(text: str) -> list[tuple[str, _ParsedCitation | None]]: raw_attributes = match.group(1) # title="foo" url="https://..." -> [("title","foo"), ("url","https://...")] - attributes = dict(_ATTR_RE.findall(raw_attributes)) + attributes = { + k: v.replace('\\"', '"') for k, v in _ATTR_RE.findall(raw_attributes) + } title = attributes.get("title", "") url = attributes.get("url") diff --git a/tests/runtime/test_citations.py b/tests/runtime/test_citations.py index 2ce5da5a6..2806243c0 100644 --- a/tests/runtime/test_citations.py +++ b/tests/runtime/test_citations.py @@ -556,6 +556,34 @@ def test_uip_prefix_followed_by_citation_single_chunk(self): assert cited[0].data == "' + events = proc.add_chunk(text) + events.extend(proc.finalize()) + cited = [e for e in events if e.citation is not None] + assert len(cited) == 1 + assert cited[0].data == "Some text." + source = cited[0].citation.end.sources[0] + assert isinstance(source, UiPathConversationCitationSourceUrl) + assert source.url == "https://example.com" + assert source.title == 'The Peculiar Journey of "Orange"' + + def test_escaped_quotes_in_title_streamed(self): + """Escaped quotes in title still work when streamed across chunks.""" + proc = CitationStreamProcessor() + events = proc.add_chunk(r'Text.') + events.extend(proc.finalize()) + cited = [e for e in events if e.citation is not None] + assert len(cited) == 1 + assert cited[0].citation.end.sources[0].title == 'Say "hi there"' + class TestExtractCitationsFromText: """Test cases for extract_citations_from_text function.""" @@ -689,6 +717,35 @@ def test_reference_without_page_number_skipped(self): assert cleaned == "UiPath reported earnings" assert citations == [] + def test_escaped_quotes_in_title(self): + """Citation with escaped quotes in title is parsed and unescaped.""" + text = ( + r'A fact' + ) + cleaned, citations = extract_citations_from_text(text) + assert cleaned == "A fact" + assert len(citations) == 1 + source = citations[0].sources[0] + assert isinstance(source, UiPathConversationCitationSourceUrl) + assert source.title == 'The "Real" Story' + assert source.url == "https://example.com" + + def test_escaped_quotes_in_title_debug_dump_repro(self): + """Reproduce the exact tag from the debug dump that was failing.""" + text = ( + r'some text.' + ) + cleaned, citations = extract_citations_from_text(text) + assert cleaned == "some text." + assert len(citations) == 1 + source = citations[0].sources[0] + assert source.title == 'The Peculiar Journey of "Orange"' + assert ( + source.url + == "https://www.vocabulary.com/articles/wordroutes/the-peculiar-journey-of-orange/" + ) + def test_different_sources_get_different_numbers(self): """Different sources get incrementing numbers.""" text = ( diff --git a/uv.lock b/uv.lock index 3665b66c7..0e6d82c19 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.25" +version = "0.8.26" source = { editable = "." } dependencies = [ { name = "httpx" }, From 7d809272ce65868b9633e450bc209a33a4f19c31 Mon Sep 17 00:00:00 2001 From: Cosmin Staicu Date: Wed, 18 Mar 2026 10:46:58 +0200 Subject: [PATCH 16/33] chore: init claude.md (#694) --- CLAUDE.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1e0fc47ac --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`uipath-langchain` is a Python SDK that extends UiPath's Python SDK with LangChain/LangGraph integration. It implements the UiPath Runtime Protocol to deploy LangGraph agents to UiPath Cloud Platform. Requires Python 3.11+. + +## Common Commands + +```bash +# Install dependencies (uses uv) +uv sync --all-extras + +# Run all tests +uv run pytest + +# Run a single test file +uv run pytest tests/path/to/test_file.py + +# Run a single test +uv run pytest tests/path/to/test_file.py::test_name + +# Lint +just lint # ruff check + httpx client lint +just format # ruff format check + fix + +# Build +uv build +``` + +## Architecture + +### Package: `src/uipath_langchain/` + +- **`runtime/`** — `UiPathLangGraphRuntime` executes LangGraph graphs within the UiPath framework. Async execution with streaming, breakpoints, and message mapping. Registered as an entry point via `uipath_langchain.runtime:register_runtime_factory`. + +- **`agent/`** — Agent implementation with sub-packages: + - `react/` — ReAct agent pattern (agent, LLM node, router, tool node, guardrails) + - `tools/` — Structured tools: context, escalation, extraction, integration, process, MCP adapters, durable interrupts. All inherit from `BaseUiPathStructuredTool`. + - `guardrails/` — Input/output validation within agent execution + - `multimodal/` — Multimodal invoke support + - `wrappers/` — Agent decorators and wrappers + +- **`chat/`** — LLM provider interfaces for OpenAI, Azure OpenAI, AWS Bedrock, Google Vertex AI. Uses **lazy imports** via `__getattr__` in `__init__.py` to keep CLI startup fast. Includes `hitl.py` with the `requires_approval` decorator for human-in-the-loop workflows. Factory pattern via `chat_model_factory.py`. + +- **`retrievers/`** and **`vectorstores/`** — Context grounding retrieval and vector storage. + +- **`guardrails/`** — Top-level guardrails with actions, enums, models, and middleware. + +- **`_cli/`** — CLI commands (`uipath init`, `uipath new`) with project templates. + +- **`_tracing/`** — OpenTelemetry instrumentation. + +- **`_utils/`** — Shared utilities: HTTP request mixin, settings, sleep policy, environment helpers. + +- **`middlewares.py`** — Entry point registered as `uipath_langchain.middlewares:register_middleware`. + +### Entry Points (pyproject.toml) + +The package registers two entry points consumed by the `uipath` CLI: +- `uipath.middleware` → `uipath_langchain.middlewares:register_middleware` +- `uipath.runtime_factory` → `uipath_langchain.runtime:register_runtime_factory` + +## Key Conventions + +- **httpx clients**: Always use `**get_httpx_client_kwargs()` when constructing `httpx.Client()` or `httpx.AsyncClient()`. A custom AST linter (`scripts/lint_httpx_client.py`) enforces this — it runs as part of `just lint`. + +- **Lazy imports**: The `chat/` module defers heavy imports (langchain_openai, openai SDK) to optimize CLI startup. Use `__getattr__` pattern in `__init__.py` when adding new chat model providers. + +- **Naming conventions for SDK methods**: `retrieve` (single by key), `retrieve_by_[field]` (single by other field), `list` (multiple resources). + +- **Testing**: pytest only (no unittest). Tests in `./tests/` mirror source structure. Use pytest-asyncio for async tests (mode: auto). A circular import test (`test_no_circular_imports.py`) auto-discovers and validates all modules. + +- **Type annotations**: All functions and classes require type annotations. Public APIs require Google-style docstrings. + +- **Linting**: Ruff with rules E, F, B, I. Line length 88. mypy with pydantic plugin for type checking. + +- **Bedrock/Vertex imports**: `bedrock.py` and `vertex.py` have per-file E402 ignores for conditional imports. From 8c82ec2fa1b07ba55f5ffc2f98d5a91cd56ebe77 Mon Sep 17 00:00:00 2001 From: Ionut Mihalache <67947900+ionut-mihalache-uipath@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:03:51 +0200 Subject: [PATCH 17/33] feat: add integration test for files (#702) --- testcases/multimodal-invoke/input.json | 3 + testcases/multimodal-invoke/langgraph.json | 6 ++ testcases/multimodal-invoke/pyproject.toml | 16 +++ testcases/multimodal-invoke/run.sh | 21 ++++ testcases/multimodal-invoke/src/assert.py | 12 +++ testcases/multimodal-invoke/src/main.py | 116 +++++++++++++++++++++ 6 files changed, 174 insertions(+) create mode 100644 testcases/multimodal-invoke/input.json create mode 100644 testcases/multimodal-invoke/langgraph.json create mode 100644 testcases/multimodal-invoke/pyproject.toml create mode 100644 testcases/multimodal-invoke/run.sh create mode 100644 testcases/multimodal-invoke/src/assert.py create mode 100644 testcases/multimodal-invoke/src/main.py diff --git a/testcases/multimodal-invoke/input.json b/testcases/multimodal-invoke/input.json new file mode 100644 index 000000000..0d72708b1 --- /dev/null +++ b/testcases/multimodal-invoke/input.json @@ -0,0 +1,3 @@ +{ + "prompt": "Describe the content of this file in one sentence." +} diff --git a/testcases/multimodal-invoke/langgraph.json b/testcases/multimodal-invoke/langgraph.json new file mode 100644 index 000000000..a2f64dcb4 --- /dev/null +++ b/testcases/multimodal-invoke/langgraph.json @@ -0,0 +1,6 @@ +{ + "dependencies": ["."], + "graphs": { + "agent": "./src/main.py:graph" + } +} diff --git a/testcases/multimodal-invoke/pyproject.toml b/testcases/multimodal-invoke/pyproject.toml new file mode 100644 index 000000000..3c34afd16 --- /dev/null +++ b/testcases/multimodal-invoke/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "multimodal-invoke" +version = "0.0.1" +description = "Test multimodal LLM invoke with file attachments via get_chat_model" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +dependencies = [ + "langgraph>=0.2.70", + "langchain-core>=0.3.34", + "langgraph-checkpoint-sqlite>=2.0.3", + "uipath-langchain[vertex,bedrock]", + "pydantic>=2.10.6", +] +requires-python = ">=3.11" + +[tool.uv.sources] +uipath-langchain = { path = "../../", editable = true } diff --git a/testcases/multimodal-invoke/run.sh b/testcases/multimodal-invoke/run.sh new file mode 100644 index 000000000..77c43f4d8 --- /dev/null +++ b/testcases/multimodal-invoke/run.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +echo "Syncing dependencies..." +uv sync + +echo "Authenticating with UiPath..." +uv run uipath auth --client-id="$CLIENT_ID" --client-secret="$CLIENT_SECRET" --base-url="$BASE_URL" + +echo "Initializing the project..." +uv run uipath init + +echo "Packing agent..." +uv run uipath pack + +echo "Running agent..." +uv run uipath run agent --file input.json + +echo "Running agent again with empty UIPATH_JOB_KEY..." +export UIPATH_JOB_KEY="" +uv run uipath run agent --trace-file .uipath/traces.jsonl --file input.json >> local_run_output.log diff --git a/testcases/multimodal-invoke/src/assert.py b/testcases/multimodal-invoke/src/assert.py new file mode 100644 index 000000000..cd4e554b6 --- /dev/null +++ b/testcases/multimodal-invoke/src/assert.py @@ -0,0 +1,12 @@ +import json + +with open("__uipath/output.json", "r", encoding="utf-8") as f: + output_data = json.load(f) + +output_content = output_data["output"] +result_summary = output_content["result_summary"] + +print(f"Success: {output_content['success']}") +print(f"Summary:\n{result_summary}") + +assert output_content["success"] is True, "Test did not succeed. See summary above." diff --git a/testcases/multimodal-invoke/src/main.py b/testcases/multimodal-invoke/src/main.py new file mode 100644 index 000000000..da618b43e --- /dev/null +++ b/testcases/multimodal-invoke/src/main.py @@ -0,0 +1,116 @@ +import logging + +from langchain_core.messages import AIMessage, HumanMessage +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import END, START, StateGraph, MessagesState +from pydantic import BaseModel, Field + +from uipath_langchain.agent.multimodal.invoke import llm_call_with_files +from uipath_langchain.agent.multimodal.types import FileInfo +from uipath_langchain.chat.chat_model_factory import get_chat_model + +logger = logging.getLogger(__name__) + +MODELS_TO_TEST = [ + "gpt-4.1-2025-04-14", + "gemini-2.5-pro", + "anthropic.claude-sonnet-4-5-20250929-v1:0", +] + +FILES_TO_TEST = [ + FileInfo( + url="https://www.w3schools.com/css/img_5terre.jpg", + name="img_5terre.jpg", + mime_type="image/jpeg", + ), + FileInfo( + url="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", + name="dummy.pdf", + mime_type="application/pdf", + ), +] + + +class GraphInput(BaseModel): + prompt: str = Field(default="Describe the content of this file in one sentence.") + + +class GraphOutput(BaseModel): + success: bool + result_summary: str + + +class GraphState(MessagesState): + prompt: str + success: bool + result_summary: str + model_results: dict + + +async def run_multimodal_invoke(state: GraphState) -> dict: + messages = [HumanMessage(content=state["prompt"])] + model_results = {} + + for model_name in MODELS_TO_TEST: + logger.info(f"Testing {model_name}...") + model = get_chat_model( + model=model_name, + temperature=0.0, + max_tokens=200, + agenthub_config="agentsplayground", + ) + test_results = {} + for file_info in FILES_TO_TEST: + label = file_info.name + logger.info(f" {label}...") + try: + response: AIMessage = await llm_call_with_files( + messages, [file_info], model + ) + logger.info(f" {label}: ✓") + test_results[label] = "✓" + except Exception as e: + logger.error(f" {label}: ✗ {e}") + test_results[label] = f"✗ {str(e)[:60]}" + model_results[model_name] = test_results + + summary_lines = [] + for model_name, results in model_results.items(): + summary_lines.append(f"{model_name}:") + for file_name, result in results.items(): + summary_lines.append(f" {file_name}: {result}") + has_failures = any( + "✗" in v for results in model_results.values() for v in results.values() + ) + + return { + "success": not has_failures, + "result_summary": "\n".join(summary_lines), + "model_results": model_results, + } + + +async def return_results(state: GraphState) -> GraphOutput: + logger.info(f"Success: {state['success']}") + logger.info(f"Summary:\n{state['result_summary']}") + return GraphOutput( + success=state["success"], + result_summary=state["result_summary"], + ) + + +def build_graph() -> StateGraph: + builder = StateGraph(GraphState, input_schema=GraphInput, output_schema=GraphOutput) + + builder.add_node("run_multimodal_invoke", run_multimodal_invoke) + builder.add_node("results", return_results) + + builder.add_edge(START, "run_multimodal_invoke") + builder.add_edge("run_multimodal_invoke", "results") + builder.add_edge("results", END) + + memory = MemorySaver() + return builder.compile(checkpointer=memory) + + +graph = build_graph() From db974140684caefe2b4019646db0d32df8ed6c66 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Wed, 18 Mar 2026 18:36:47 +0200 Subject: [PATCH 18/33] chore: assert .uiproj on init-flow testcase (#695) --- pyproject.toml | 4 ++-- src/uipath_langchain/_cli/cli_new.py | 2 +- testcases/init-flow/src/assert.py | 23 +++++++++++++++++++++++ uv.lock | 16 ++++++++-------- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fc5ca1893..71f10ca6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-langchain" -version = "0.8.26" +version = "0.8.27" 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.10.13, <2.11.0", + "uipath>=2.10.19, <2.11.0", "uipath-core>=0.5.2, <0.6.0", "uipath-platform>=0.0.23, <0.1.0", "uipath-runtime>=0.9.1, <0.10.0", diff --git a/src/uipath_langchain/_cli/cli_new.py b/src/uipath_langchain/_cli/cli_new.py index 5f819aa4f..255a15dae 100644 --- a/src/uipath_langchain/_cli/cli_new.py +++ b/src/uipath_langchain/_cli/cli_new.py @@ -31,7 +31,7 @@ def generate_pyproject(target_directory, project_name): description = "{project_name}" authors = [{{ name = "John Doe", email = "john.doe@myemail.com" }}] dependencies = [ - "uipath-langchain>=0.2.0", + "uipath-langchain>=0.8.0, <0.9.0", ] requires-python = ">=3.11" """ diff --git a/testcases/init-flow/src/assert.py b/testcases/init-flow/src/assert.py index 0980f2a12..9768692c5 100644 --- a/testcases/init-flow/src/assert.py +++ b/testcases/init-flow/src/assert.py @@ -4,6 +4,29 @@ print("Checking init-flow output...") +# Check studio_metadata.json was created by init +studio_metadata_file = ".uipath/studio_metadata.json" +assert os.path.isfile(studio_metadata_file), "studio_metadata.json not found" +with open(studio_metadata_file, 'r', encoding='utf-8') as f: + studio_metadata_data = json.load(f) + +assert "schemaVersion" in studio_metadata_data, "Missing 'schemaVersion' in studio_metadata.json'" +assert "codeVersion" in studio_metadata_data, "Missing 'codeVersion' in studio_metadata.json'" + +# Check project.uiproj was created by init +uiproj_file = "project.uiproj" +assert os.path.isfile(uiproj_file), "project.uiproj not found" + +with open(uiproj_file, 'r', encoding='utf-8') as f: + uiproj_data = json.load(f) + +assert "ProjectType" in uiproj_data, "Missing 'ProjectType' in project.uiproj" +assert uiproj_data["ProjectType"] in ("Agent", "Function"), f"Unexpected ProjectType: {uiproj_data['ProjectType']}" +assert "Name" in uiproj_data, "Missing 'Name' in project.uiproj" +assert uiproj_data["Name"], "Name is empty in project.uiproj" + +print(f"project.uiproj found: ProjectType={uiproj_data['ProjectType']}, Name={uiproj_data['Name']}") + # Check NuGet package uipath_dir = ".uipath" assert os.path.exists(uipath_dir), "NuGet package directory (.uipath) not found" diff --git a/uv.lock b/uv.lock index 0e6d82c19..58638e334 100644 --- a/uv.lock +++ b/uv.lock @@ -3289,7 +3289,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.13" +version = "2.10.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -3312,9 +3312,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/3a/3a93f5c54078b993e6cecdae6069ebafe122fceb639b1f59ad0aa3f1b765/uipath-2.10.13.tar.gz", hash = "sha256:13795c00dfb7391f248efb6ae4b96f096a4a8131d0df5f8ec0b7f265be1d0e10", size = 2456921, upload-time = "2026-03-13T07:51:16.484Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/0d/75c5d1f531be94c704c58a1e08fba2ea5190680f306679a56973d78cc189/uipath-2.10.19.tar.gz", hash = "sha256:79cab23a002d76e4a505625f3cb914222fd8f14752d872fde6d9177ff21f790f", size = 2461741, upload-time = "2026-03-18T16:27:25.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/f9/c8745f866a39047f529ee0413aedd8fbf81f7ffd5dd46862cb5e6221d58d/uipath-2.10.13-py3-none-any.whl", hash = "sha256:fc1c8503b9cc3538cf3003cb10e1e3e736529aba4272086066402fde21154b65", size = 357769, upload-time = "2026-03-13T07:51:14.599Z" }, + { url = "https://files.pythonhosted.org/packages/1d/73/c602a896d95ea2b468e6a5f82a66bf484a973a5826fec931d9c92e80d6c8/uipath-2.10.19-py3-none-any.whl", hash = "sha256:071aa44565fc126ac76f9222b38c774ea71385f6631cffffbc859056eb95e067", size = 358660, upload-time = "2026-03-18T16:27:23.424Z" }, ] [[package]] @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.26" +version = "0.8.27" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -3400,7 +3400,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.10.13,<2.11.0" }, + { name = "uipath", specifier = ">=2.10.19,<2.11.0" }, { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, { name = "uipath-platform", specifier = ">=0.0.23,<0.1.0" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, @@ -3425,7 +3425,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.0.23" +version = "0.0.28" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3435,9 +3435,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/c1/f1cfd23d977fc2fec2e880dac42b1fdb78c28fef80008ebf4bd43ca9b12f/uipath_platform-0.0.23.tar.gz", hash = "sha256:a84d9da29865155080efcc837f6c2b2186d480e34fc877dec06f3ec315c1e52c", size = 269669, upload-time = "2026-03-13T07:50:06.953Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/83/b16f3b4956ec3e77187c9127c0cfa0a46edfc962f6fa6e937f335185bcbb/uipath_platform-0.0.28.tar.gz", hash = "sha256:6f2ce0785c4a046784c27588e1288f119ff8bbdaee9f5b8bb932dc89f7a5540b", size = 283924, upload-time = "2026-03-18T16:26:05.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/32/167abe3730ab8c0dd89abee6ebea6e1a35e0a1548be620a491a60bc7cc0d/uipath_platform-0.0.23-py3-none-any.whl", hash = "sha256:e2ea4d9341540a5a02baca8e07ba05dfb8947f2d525f8677834f81c735826772", size = 161946, upload-time = "2026-03-13T07:50:04.956Z" }, + { url = "https://files.pythonhosted.org/packages/65/07/dc956e18ad9305d7dd7fb48f9eba1868111f613d16e3d6393c80ca1ad5ce/uipath_platform-0.0.28-py3-none-any.whl", hash = "sha256:f94a8b729e6ab59d522b6d28cb7851d34d00a538418458fcf8272c75b72b33d7", size = 175623, upload-time = "2026-03-18T16:26:04.252Z" }, ] [[package]] From 55aec5418251e0fd987fb70a84df7e553fd90dc0 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Thu, 19 Mar 2026 09:37:24 +0200 Subject: [PATCH 19/33] feat: add example for process tool http error handling (#707) --- CLAUDE.md | 3 + pyproject.toml | 4 +- .../agent/exceptions/__init__.py | 2 + .../agent/exceptions/helpers.py | 66 +++++++++ .../agent/tools/process_tool.py | 43 +++++- tests/agent/test_exception_helpers.py | 140 ++++++++++++++++++ uv.lock | 4 +- 7 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 src/uipath_langchain/agent/exceptions/helpers.py create mode 100644 tests/agent/test_exception_helpers.py diff --git a/CLAUDE.md b/CLAUDE.md index 1e0fc47ac..8ce88fa9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ uv build - `tools/` — Structured tools: context, escalation, extraction, integration, process, MCP adapters, durable interrupts. All inherit from `BaseUiPathStructuredTool`. - `guardrails/` — Input/output validation within agent execution - `multimodal/` — Multimodal invoke support + - `exceptions/` — Structured error types (`AgentRuntimeError`, `AgentStartupError`) and helpers for agent error handling - `wrappers/` — Agent decorators and wrappers - **`chat/`** — LLM provider interfaces for OpenAI, Azure OpenAI, AWS Bedrock, Google Vertex AI. Uses **lazy imports** via `__getattr__` in `__init__.py` to keep CLI startup fast. Includes `hitl.py` with the `requires_approval` decorator for human-in-the-loop workflows. Factory pattern via `chat_model_factory.py`. @@ -77,3 +78,5 @@ The package registers two entry points consumed by the `uipath` CLI: - **Linting**: Ruff with rules E, F, B, I. Line length 88. mypy with pydantic plugin for type checking. - **Bedrock/Vertex imports**: `bedrock.py` and `vertex.py` have per-file E402 ignores for conditional imports. + +- **Exception handling in `agent/`**: Use the error types and helpers from `agent/exceptions/`. Do not raise raw exceptions or invent new error types. New error codes may be defined. diff --git a/pyproject.toml b/pyproject.toml index 71f10ca6e..7a3f7f500 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath-langchain" -version = "0.8.27" +version = "0.8.28" 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.10.19, <2.11.0", "uipath-core>=0.5.2, <0.6.0", - "uipath-platform>=0.0.23, <0.1.0", + "uipath-platform>=0.0.27, <0.1.0", "uipath-runtime>=0.9.1, <0.10.0", "langgraph>=1.0.0, <2.0.0", "langchain-core>=1.2.11, <2.0.0", diff --git a/src/uipath_langchain/agent/exceptions/__init__.py b/src/uipath_langchain/agent/exceptions/__init__.py index 04eb05d02..9b8ef0739 100644 --- a/src/uipath_langchain/agent/exceptions/__init__.py +++ b/src/uipath_langchain/agent/exceptions/__init__.py @@ -4,10 +4,12 @@ AgentStartupError, AgentStartupErrorCode, ) +from .helpers import raise_for_enriched __all__ = [ "AgentStartupError", "AgentRuntimeError", "AgentStartupErrorCode", "AgentRuntimeErrorCode", + "raise_for_enriched", ] diff --git a/src/uipath_langchain/agent/exceptions/helpers.py b/src/uipath_langchain/agent/exceptions/helpers.py new file mode 100644 index 000000000..eea3e73c6 --- /dev/null +++ b/src/uipath_langchain/agent/exceptions/helpers.py @@ -0,0 +1,66 @@ +"""Helpers for raising structured errors from HTTP exceptions.""" + +from collections import defaultdict + +from uipath.platform.errors import EnrichedException +from uipath.runtime.errors import UiPathErrorCategory + +from .exceptions import AgentRuntimeError, AgentRuntimeErrorCode + + +def raise_for_enriched( + e: EnrichedException, + known_errors: dict[tuple[int, str | None], tuple[str, UiPathErrorCategory]], + *, + title: str, + **context: str, +) -> None: + """Raise AgentRuntimeError if the exception matches a known error pattern. + + Matches on ``(status_code, error_code)`` pairs. Use ``None`` as error_code + to match any error with that status code. More specific matches (with + error_code) are tried first. + + Each value is a ``(template, category)`` pair. Message templates can use + ``{keyword}`` placeholders filled from *context*, plus ``{message}`` for + the server's own error message. + + Does nothing if no match is found — caller should re-raise the original. + + Example:: + + try: + await client.processes.invoke_async(name=name, folder_path=folder) + except EnrichedException as e: + raise_for_enriched( + e, + { + (404, "1002"): ("Process not found.", UiPathErrorCategory.DEPLOYMENT), + (409, None): ("Conflict: {message}", UiPathErrorCategory.DEPLOYMENT), + }, + title=f"Failed to execute tool '{tool_name}'", + ) + raise + """ + info = e.error_info + error_code = info.error_code if info else None + server_message = (info.message if info else None) or "" + context["message"] = server_message + + # Try specific match first, then wildcard + entry = known_errors.get((e.status_code, error_code)) + if entry is None: + entry = known_errors.get((e.status_code, None)) + if entry is None: + return + + template, category = entry + detail = template.format_map(defaultdict(lambda: "", context)) + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.HTTP_ERROR, + title=title, + detail=detail, + category=category, + status=e.status_code, + should_wrap=False, + ) from e diff --git a/src/uipath_langchain/agent/tools/process_tool.py b/src/uipath_langchain/agent/tools/process_tool.py index 233e1b060..4d54a7b3a 100644 --- a/src/uipath_langchain/agent/tools/process_tool.py +++ b/src/uipath_langchain/agent/tools/process_tool.py @@ -10,10 +10,13 @@ from uipath.eval.mocks import mockable from uipath.platform import UiPath from uipath.platform.common import WaitJobRaw +from uipath.platform.errors import EnrichedException from uipath.platform.orchestrator import JobState +from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain._utils import get_execution_folder_path from uipath_langchain._utils.durable_interrupt import durable_interrupt +from uipath_langchain.agent.exceptions import raise_for_enriched from uipath_langchain.agent.react.job_attachments import get_job_attachments from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model from uipath_langchain.agent.react.types import AgentGraphState @@ -27,6 +30,21 @@ from .utils import sanitize_tool_name +_START_JOBS_ERRORS: dict[tuple[int, str | None], tuple[str, UiPathErrorCategory]] = { + (404, "1002"): ( + "Could not find process for tool '{tool}'. Please check if the process is deployed in the configured folder.", + UiPathErrorCategory.DEPLOYMENT, + ), + (400, "1100"): ( + "Could not find folder for tool '{tool}'. Please check if the folder exists and is accessible by the robot.", + UiPathErrorCategory.DEPLOYMENT, + ), + (409, None): ( + "Cannot start process for tool '{tool}': {message}", + UiPathErrorCategory.DEPLOYMENT, + ), +} + def create_process_tool(resource: AgentProcessToolResourceConfig) -> StructuredTool: """Uses interrupt() to suspend graph execution until process completes (handled by runtime).""" @@ -61,14 +79,23 @@ async def invoke_process(**_tool_kwargs: Any): @durable_interrupt async def start_job(): client = UiPath() - job = await client.processes.invoke_async( - name=process_name, - input_arguments=input_arguments, - folder_path=folder_path, - attachments=attachments, - parent_span_id=parent_span_id, - parent_operation_id=parent_operation_id, - ) + try: + job = await client.processes.invoke_async( + name=process_name, + input_arguments=input_arguments, + folder_path=folder_path, + attachments=attachments, + parent_span_id=parent_span_id, + parent_operation_id=parent_operation_id, + ) + except EnrichedException as e: + raise_for_enriched( + e, + _START_JOBS_ERRORS, + title=f"Failed to execute tool '{resource.name}'", + tool=resource.name, + ) + raise if job.key: bts_key = ( diff --git a/tests/agent/test_exception_helpers.py b/tests/agent/test_exception_helpers.py new file mode 100644 index 000000000..624ecb940 --- /dev/null +++ b/tests/agent/test_exception_helpers.py @@ -0,0 +1,140 @@ +"""Tests for raise_for_enriched helper.""" + +import json + +import httpx +import pytest +from uipath.platform.errors import EnrichedException +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_langchain.agent.exceptions import AgentRuntimeError, AgentRuntimeErrorCode +from uipath_langchain.agent.exceptions.helpers import raise_for_enriched + + +def _make_enriched( + status: int, + body: dict[str, object] | None = None, + url: str = "https://cloud.uipath.com/org/tenant/orchestrator_/api/v1", +) -> EnrichedException: + content = json.dumps(body).encode() if body else b"" + request = httpx.Request("POST", url) + response = httpx.Response( + status_code=status, + request=request, + headers={"content-type": "application/json"}, + content=content, + ) + http_err = httpx.HTTPStatusError("error", request=request, response=response) + enriched = EnrichedException(http_err) + enriched.__cause__ = http_err + return enriched + + +_KNOWN_ERRORS: dict[tuple[int, str | None], tuple[str, UiPathErrorCategory]] = { + (404, "1002"): ( + "Could not find process for tool '{tool}'.", + UiPathErrorCategory.USER, + ), + (400, "1100"): ( + "Folder not found for tool '{tool}'.", + UiPathErrorCategory.USER, + ), + (409, None): ( + "Cannot start tool '{tool}': {message}", + UiPathErrorCategory.DEPLOYMENT, + ), +} + +_TITLE = "Failed to execute tool 'MyProcess'" + + +class TestMatching: + def test_exact_match(self) -> None: + err = _make_enriched(404, {"errorCode": "1002", "message": "Not found"}) + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="MyProcess") + assert exc_info.value.error_info.category == UiPathErrorCategory.USER + assert "MyProcess" in exc_info.value.error_info.detail + + def test_wildcard_match(self) -> None: + err = _make_enriched(409, {"message": "Already running"}) + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="MyTool") + assert "Already running" in exc_info.value.error_info.detail + assert "MyTool" in exc_info.value.error_info.detail + + def test_specific_beats_wildcard(self) -> None: + errors: dict[tuple[int, str | None], tuple[str, UiPathErrorCategory]] = { + (404, "1002"): ("specific: {tool}", UiPathErrorCategory.DEPLOYMENT), + (404, None): ("wildcard: {tool}", UiPathErrorCategory.SYSTEM), + } + err = _make_enriched(404, {"errorCode": "1002"}) + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_enriched(err, errors, title=_TITLE, tool="T") + assert exc_info.value.error_info.detail == "specific: T" + assert exc_info.value.error_info.category == UiPathErrorCategory.DEPLOYMENT + + def test_no_match_does_nothing(self) -> None: + err = _make_enriched(500, {"message": "Server error"}) + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T") + + def test_unknown_error_code_does_nothing(self) -> None: + err = _make_enriched(404, {"errorCode": "9999"}) + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T") + + +class TestTitleAndDetail: + def test_title_is_fixed(self) -> None: + err = _make_enriched(404, {"errorCode": "1002", "message": "Not found"}) + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T") + assert exc_info.value.error_info.title == _TITLE + + def test_detail_uses_template(self) -> None: + err = _make_enriched(404, {"errorCode": "1002", "message": "Not found"}) + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="InvoiceBot") + assert ( + exc_info.value.error_info.detail + == "Could not find process for tool 'InvoiceBot'." + ) + + def test_message_placeholder(self) -> None: + err = _make_enriched(409, {"message": "Job conflict"}) + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T") + assert "Job conflict" in exc_info.value.error_info.detail + + def test_empty_message_when_no_error_info(self) -> None: + err = _make_enriched(409, body=None) + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T") + assert "Cannot start tool 'T': " in exc_info.value.error_info.detail + + def test_missing_context_renders_as_unknown(self) -> None: + err = _make_enriched(404, {"errorCode": "1002"}) + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE) + assert "" in exc_info.value.error_info.detail + + +class TestErrorProperties: + def test_error_code(self) -> None: + err = _make_enriched(404, {"errorCode": "1002"}) + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T") + assert exc_info.value.error_info.code == AgentRuntimeError.full_code( + AgentRuntimeErrorCode.HTTP_ERROR + ) + + def test_status_code_preserved(self) -> None: + err = _make_enriched(400, {"errorCode": "1100"}) + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T") + assert exc_info.value.error_info.status == 400 + + def test_original_exception_chained(self) -> None: + err = _make_enriched(404, {"errorCode": "1002"}) + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T") + assert exc_info.value.__cause__ is err diff --git a/uv.lock b/uv.lock index 58638e334..e8473fb8f 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.27" +version = "0.8.28" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -3402,7 +3402,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "uipath", specifier = ">=2.10.19,<2.11.0" }, { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, - { name = "uipath-platform", specifier = ">=0.0.23,<0.1.0" }, + { name = "uipath-platform", specifier = ">=0.0.27,<0.1.0" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, ] provides-extras = ["vertex", "bedrock"] From ab5b4404e036cdc860d4ba5d9b625d2f80da7c34 Mon Sep 17 00:00:00 2001 From: Runnan Jia Date: Thu, 19 Mar 2026 09:07:05 -0700 Subject: [PATCH 20/33] fix: inject X-UiPath-License-RefId in transport layer [AE-1109] (#709) Co-authored-by: Claude Opus 4.6 (1M context) --- src/uipath_langchain/chat/openai.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/uipath_langchain/chat/openai.py b/src/uipath_langchain/chat/openai.py index 2e3b99764..127df5a7c 100644 --- a/src/uipath_langchain/chat/openai.py +++ b/src/uipath_langchain/chat/openai.py @@ -17,6 +17,25 @@ logger = logging.getLogger(__name__) +# Module-level storage for the current license ref ID. +# Set by uipath-agents when a model_run span starts, read by the +# transport to inject X-UiPath-License-RefId on LLM calls. +# A plain global is used because the LangChain callback (which sets the +# value) and the httpx transport (which reads it) run on different +# threads, so neither ContextVar nor threading.local work. +_current_license_ref_id: str | None = None + + +def set_license_ref_id(value: str | None) -> None: + """Set the license ref ID for injection on LLM requests.""" + global _current_license_ref_id + _current_license_ref_id = value + + +def _get_license_ref_id() -> str | None: + """Read the current license ref ID.""" + return _current_license_ref_id + def _rewrite_openai_url( original_url: str, params: httpx.QueryParams @@ -45,6 +64,13 @@ def _rewrite_openai_url( return httpx.URL(new_url_str) +def _inject_license_ref_id(request: httpx.Request) -> None: + """Inject X-UiPath-License-RefId header if a model_run span is active.""" + license_ref_id = _get_license_ref_id() + if license_ref_id: + request.headers["X-UiPath-License-RefId"] = license_ref_id + + class UiPathURLRewriteTransport(httpx.AsyncHTTPTransport): def __init__(self, verify: bool = True, **kwargs): super().__init__(verify=verify, **kwargs) @@ -53,6 +79,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: new_url = _rewrite_openai_url(str(request.url), request.url.params) if new_url: request.url = new_url + _inject_license_ref_id(request) return await super().handle_async_request(request) @@ -65,6 +92,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: new_url = _rewrite_openai_url(str(request.url), request.url.params) if new_url: request.url = new_url + _inject_license_ref_id(request) return super().handle_request(request) From 694cad25c0cc9ec9df0cdd2593b0bc83e07128a6 Mon Sep 17 00:00:00 2001 From: Runnan Jia Date: Thu, 19 Mar 2026 09:56:25 -0700 Subject: [PATCH 21/33] chore: bump version to 0.8.29 (#711) Co-authored-by: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7a3f7f500..1c13bbb9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.28" +version = "0.8.29" 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/uv.lock b/uv.lock index e8473fb8f..4afd3fe2b 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.28" +version = "0.8.29" source = { editable = "." } dependencies = [ { name = "httpx" }, From 453b92dadef4ece47e41e288c6878e9980e2df2b Mon Sep 17 00:00:00 2001 From: dianagrecu-uipath Date: Fri, 20 Mar 2026 12:14:42 +0200 Subject: [PATCH 22/33] chore: upgrade uipath and uipath-platform versions (#710) --- pyproject.toml | 6 +++--- uv.lock | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1c13bbb9c..e685a836e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath-langchain" -version = "0.8.29" +version = "0.9.0" 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.10.19, <2.11.0", + "uipath>=2.10.22, <2.11.0", "uipath-core>=0.5.2, <0.6.0", - "uipath-platform>=0.0.27, <0.1.0", + "uipath-platform>=0.1.1, <0.2.0", "uipath-runtime>=0.9.1, <0.10.0", "langgraph>=1.0.0, <2.0.0", "langchain-core>=1.2.11, <2.0.0", diff --git a/uv.lock b/uv.lock index 4afd3fe2b..75d4fc7aa 100644 --- a/uv.lock +++ b/uv.lock @@ -3289,7 +3289,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.19" +version = "2.10.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -3312,9 +3312,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/0d/75c5d1f531be94c704c58a1e08fba2ea5190680f306679a56973d78cc189/uipath-2.10.19.tar.gz", hash = "sha256:79cab23a002d76e4a505625f3cb914222fd8f14752d872fde6d9177ff21f790f", size = 2461741, upload-time = "2026-03-18T16:27:25.559Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/a2/694484060d07a25c27541d31e9b6bc37f693754b36e4ad1f29d2379dd6a7/uipath-2.10.22.tar.gz", hash = "sha256:7f55aae793c667720c490535c9a78705a322218695afb532d3a79545b313af60", size = 2461940, upload-time = "2026-03-20T10:00:11.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/73/c602a896d95ea2b468e6a5f82a66bf484a973a5826fec931d9c92e80d6c8/uipath-2.10.19-py3-none-any.whl", hash = "sha256:071aa44565fc126ac76f9222b38c774ea71385f6631cffffbc859056eb95e067", size = 358660, upload-time = "2026-03-18T16:27:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/b5068237c914c7cc6f8e2f49292fe3672eff0e11c716b41c667d7e7f74d3/uipath-2.10.22-py3-none-any.whl", hash = "sha256:85e80d84689e75bcff6fd8312f26604294ed96e22a1b39d64b6075b49f43933c", size = 358922, upload-time = "2026-03-20T10:00:10.004Z" }, ] [[package]] @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.29" +version = "0.9.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -3400,9 +3400,9 @@ 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.10.19,<2.11.0" }, + { name = "uipath", specifier = ">=2.10.22,<2.11.0" }, { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, - { name = "uipath-platform", specifier = ">=0.0.27,<0.1.0" }, + { name = "uipath-platform", specifier = ">=0.1.1,<0.2.0" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, ] provides-extras = ["vertex", "bedrock"] @@ -3425,7 +3425,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.0.28" +version = "0.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3435,9 +3435,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/83/b16f3b4956ec3e77187c9127c0cfa0a46edfc962f6fa6e937f335185bcbb/uipath_platform-0.0.28.tar.gz", hash = "sha256:6f2ce0785c4a046784c27588e1288f119ff8bbdaee9f5b8bb932dc89f7a5540b", size = 283924, upload-time = "2026-03-18T16:26:05.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/6f/09af81298e5400e414eedc7057baf43742b8acf4c89ef724a9e87f2cce66/uipath_platform-0.1.1.tar.gz", hash = "sha256:f1faabb10ea0adee7e06d8d864c2d1702301534b505f8602723821662cb1b9c9", size = 285000, upload-time = "2026-03-20T09:58:18.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/07/dc956e18ad9305d7dd7fb48f9eba1868111f613d16e3d6393c80ca1ad5ce/uipath_platform-0.0.28-py3-none-any.whl", hash = "sha256:f94a8b729e6ab59d522b6d28cb7851d34d00a538418458fcf8272c75b72b33d7", size = 175623, upload-time = "2026-03-18T16:26:04.252Z" }, + { url = "https://files.pythonhosted.org/packages/84/a4/9c9130d2119e76b730e97287c5c10bee58889ea9b7644ade9efe5ccb91ce/uipath_platform-0.1.1-py3-none-any.whl", hash = "sha256:1987a617b1b10ee88c26393cbba371196e1f55b481d59f88b0b386f82987bf51", size = 175883, upload-time = "2026-03-20T09:58:16.956Z" }, ] [[package]] From e2ead0f6d8fe990837bd259b0a0cc9bcec01e7ac Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Sun, 22 Mar 2026 18:38:48 +0200 Subject: [PATCH 23/33] chore: add app binding for ticket classification sample (#717) --- .../app-binding-package-requirements.png | Bin 0 -> 30032 bytes samples/ticket-classification/README.md | 20 ++++++++++++++ samples/ticket-classification/bindings.json | 26 ++++++++++++++++++ samples/ticket-classification/main.py | 2 +- 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 docs/sample_images/ticket-classification/app-binding-package-requirements.png create mode 100644 samples/ticket-classification/bindings.json diff --git a/docs/sample_images/ticket-classification/app-binding-package-requirements.png b/docs/sample_images/ticket-classification/app-binding-package-requirements.png new file mode 100644 index 0000000000000000000000000000000000000000..5e1dca9932eb36b00bf544131189b24d9d414f5a GIT binary patch literal 30032 zcmeFZcTiK?+ct~`1wDXRjv!q{L6jySU?>rhB2`5?p$7;MdT$XBP=O%QyMXi#A)!|l zA@trtKxznt5+DQ!yz!j#duN_!=KJTHdERg4n|J-Svu9{eS#s$tGogEKs?|_2d zJpFiLqhIZE402arpJ>KJJ53QRp?M5LYs%->qcEbf1M>$$3o2 z5&c#8T!*Bxs;p&=;bw+S5l7NoCT5ecDwE}~k}B@rm(KH|pzp=*pE0YUHbJID-qlp! zEPN`N$Jx004fTb7w|Nht<`q9O58>(GKWS)^nf_4u_cGQRN1`8&vyk?f>N(7R>ym3;VL`D($hY7e@JKKnH69nAzo}{OB$Nt`?E4qYbr1Q zo?fJiE*-oXxDjX8vh&b)|7Ydw>~{C0GzAoa_sQtbK&3ka_Yq4$O7OK^LzZ;L)N3Of4rCHu^)OT-JdGf@JbFAzz6dJv*sp%n!j@d{kN z>Mm?()%vp=$k=k%yWBV{nWezCJZ66;v-idHz+{}j2>VPP%&6dJcM#FPF)HQB##ZL^ zV>kS?vSDH}YR`|z?>6kJ4RV_f`_l3%HFf;kv#I@4cs%a3uNwzyKYb8n^5A8+tAK#? z!AJyH$I>M6^Xi+vxvSmyFFL;6S-oiMGp{M&dX|(G{Vdtm)=;r#wm~fD6x<9a-+}^v zi^28{MGMEh5vy5bwl~q8Pq>W>GWYGNBNr6Yc9|^&37AdqyEvwwmS=y0r)ka~hHaXO z5n%--GMvaoG;_+b00qTWKC*@Y#5AU>U6oEes27@9TCIplHTM<{xz8beTDjFVeB_gM zvc@lxTy0l~*+yk)X~ayW;jOsnVElX<41f|Jb%2zM$pf?`@&*1!M+B^|C3f=gO}ihl zULLn5FW7lK=x~MU6Qx8aKWmi$kVs%$w@(94w6H+b%HD9XormAxO8VTdH=it9Ajx#N z+O<9R(_=p^&l9^ca`9!Zr0SNd^9UWXE5fz`PX6*U==Y@izwBeK2hoOJkH{FT(vsZQ zN^vDy1y)0S-R8*o_OvQY0jE1Z2bTP?X9_np*3R}x`lb{Ww$rKiDFhg7tzxODAS>23 z^SFE#ag^x7Msk{afZd{G2kZ}3d{1n4>+7;!Z5sk>M_E&=H* zCs2Ea&FZGt4|qN;)5oFtmxeD+_9{ca^4wej)d);lg8#Mu& zQmvGD&eI6i&K+d$`1HU@vp^$Zg6GWosS!6c!Ql+cac#zPn|>e-l1t2lq@f7OHMe@` z;K&;#<;Elw^n&q5-MUN0K`?#moj{7nyJHNulsar;0bm+4a26MmK_DUC) z@{`A%p$I;0=d=BK_s$x*onjwW1fo)4*y~uv7?L}d610(=0LWZkksDdrHJG_o;?wq$ zU(^5CdSeoy!GnIWHK&IIX1h<$KbY(Dnre{50m~POVvr8>?Wxvrkj=Co{_FDlBFm6* z>r*#+W(oB^Igpt2BO!9GoPz$G@|2g$&v*ft<+E`5X>ed*0QoTrrHYa|Tk@ET_xJgt zNvK||eOSXK_2@2!`7x^z_qe1wI{PYYYD7oOYpHSr)vo`+HSMz2&%F;!+`lMETK#)tTX2rylsBFxAd^?CK^mR2Rz@O zH7bS&jH}wu7iL2Wuw%Z{XV(5e|(>urc z!cLi%ksA(7kkD?#_y_iC$kVUqa>IR)7c8+cFw5{6^OwdbisRBM-`ddf)VN`T28iU4 zYqf$k^IdvWW75dU#0X7tW)j4-?ZjoZpriDUoSEJpnMpe)=79%AUxp40V7*%n%jqlt zT4)D|K`wD}etl@Xt4|itdU;}J+H;I9eW)5Qi9<;KtX{Yboc^Vr?EV`brgBbR=}t8SZzlTO)i@+#4C{Hw zoC*9Z9KPj?&3Zv1`E|HIjj1m`i|sP%3&?{P5Jp}TGlkqeOXJ$jWDaF}Ehr)fXvLrLHphD3iseygC%0&Y#(sfiLjrzWDewvxI} zp0{3duw!0*1RjL>6I18=;S3$Et~EVPC@m)M^{?P>S+6>aA2Gq2plmSZPUP!FN&4H4 zMiVoA!!j09ZG^b1km!w2(dUA?gh_3j#CZ@k)&PZ=Cl##>i>R2k_RG=7Hok3Y!OHY0yGD?O!T?al&!11 z6?;z0t$}_>=^3vcGbQ_}T^&Bbbi^Wx?py^_x3n`CCL?yQ8LzfQPqnT2-pB|zR|Jdc zWMMJDWmFGb5p#Nn@fy!;bJzL1NB$BnCL~gp__=U8ady+mRatx0uLIHUNoPJY!Ij4z zMBA-L=uwMlwU=jQosLotT;;d(kWWXd?H)>i!jbu$0lxPcpF>6SZui7-X{ROH0YC`# z+C*`7$#oy-xq_K%Ocy;(IXdjF9H6Cwz==hjd&YF=Ju4aWqHk94nw0)6$3&CI7t4wE zB|p}hkg|T4F}s(}d~*}H2U<;h-G_WJ6u11o?^RmnnTz%Q*t~+W&OBfySiaMv}83k^`?8xL&;!1Qpzh^pD%=IkT z5|bNue7}L&ao(VKHViV-@gr{`sGA4E6h6h08}_s~XR*2g4Qw&eYZNkNI|1iIRV;Xt zcfF3fI$*-!D=l0=6Ca#fUFqw^&v@6+)}_kF!>*&H^)BTg?nyJqtmU-C`X;JeLq-9T)SUiP%R99 zU2XxZCA%aNKZaqZT2< z*N#lyx<7rvSSiBc%hwTyNy{z=)vp-S$9u!(s>T6Ijp{Qj@S((1_Nete_C(y)^t4tZ z+(8Pusa#(66rBK?A}q64+uzC~uAl9Da|9?A07q7GmeY=hZ{~q3MQ%LsGTCGZ+vBY6 zq^@E(VBmzUCJHRakFiylCLFPA*-MxH-dyD>9v{wDx~kWS?9IZxP~(dgo%=T(lI&)p z8$T}^I?mNVh2fq@;+&ftg`wMbSwzdO0ate}9WF%`I~`|H(Dnsj-a-q&v|1+aF!8mxVP- z%FbchbRYXBRG%(sGSs&{YE4C2yxy%l#K1Q9rUNNZ`jh>y^jeUaDigvq<{m}m7k+Qm zjQ3b2RQa0rOA>pMi8i_I(WXiC1Ks{SrBI~W_(DW*zeN7y@(jM4Ea%}fmzCkK z4u5RDURDQ6c{N{SiZ=!y#=jHtrYzG7-GlmWJ{g@cnN&t4Kw~LXjnkxa?X*22Yjlw- zim5@F>R!sn={xGiM2JtW&}C>z+nTx+!niOz%Ksn;b3UHk#xC-i_tb+UPHOxnF*-VK z=2a)G->5(Hsk?lDX+gr#jaJ2*UB)C-@Ys@F%6w=X#D3FWv~YyFt)WLCns9w0uy_f--_*{YR>fU5DQG42w75G^%_=c!Y^FL0)+N2QzF`gH+YpjNxRJLpGR6B#&3) zT836-)_qGlJ)4ldrAt06X%iR`>fXkMN0j$nz@1Z^6TGwmDZM68&WG4t`>e z@1f<57jE?rhVH13(Ut9Le6cbP$}7XdBkAEgpC0?zfCrTTb0Fks-Roi0J??|}2i_Lb z+bU+|*U9+B0wqxMW?2>_*ai;`foX>Y=c?P0L(tZZ7 zLLr`NRv!^h1tt4IF&rm(OzJ@Wz#H*3s)KsnfY${dYHgyJi7|@XR*Aah0k~D=bw8`% z-0SuY3zpNBcaG0dRVPjSHQ>0x?UIG76MYjnUnq1eAsZ?0{Yc0KfhnwgoP26lfWqj5 z*j?;y<)TL*P?Cj=NrY14jn>vv5lx53(BS;wc;QZYhGDOEVHOP~--zSg$+-(Rzl%fam7 z|HPQ`F2q*|-#6awZTJRQOq3I~?qDsOA0IF`c^wP=5GI$`14{PV9kemD$r@uV@^R?c z`JQ#%V92!!jl{TE&2FmFxmf7We{(I1%QsJtY5+xITnT=&E<#X)>3VzJm;HAXj03ok zc@`dA$nO>2S}94WcxKMHo`iu+jv*pa%g9RTVdy4vqgR1T=XZpK(!oE`US9JpzD*@D zqS+Wde0uk#u@&ULwcFS7pP$odQ8mjt#wwr_nHc& z$`)VW9xpQ+2sIB6fQ6MbbvLmyolSupF0td*)aE-*f5h>TQ5Iufvz`5T7Om|eQeTxG9*7$ zo2vw%gAxy@XRN~3pF4^|)ll!o4K)Um4TtnNvZqp@4Z-NWzUyz@dtUP(j8`4}U0lKE zaYo!+zc6lXBW@v<`#R(J2G^wrrB|m~a}86+3Gw5E&XuxO_WPbOK3UnvUa6~UfeihA;W6CCo7KLnD828dR`0{}dciXx!7K~PQ_4&fPJwm(mR&j%#`bgK z(DE41oD!$)>vUIYXBM3eGuPsrh+?0A1?RR1dGEfFR_{qmW58#)&KIIv3$FZ3<_SDl zyxtef;u}3IHm`56pl$GEtox|nWSeEaW55R`KjBof#~sN$v0bsqy}4p+pB`ut(Gv9o za~WIAA8IRdB(8S$Bd@!%;7z?w{=V_}jRwy&$~7<2GpEvr6A2M@Yvbl9yu$?*R5;_Uw2(AH6l+O#~YQGvtvbnxXK+}6N$%IBl& z2oudx-sPJ(>^I~#IS%rB9WU(Znee+?4ISYXbA-dxrgB!1Eo6VbV5^=W)kIdR%6}UC z4IMq)4Y|4HSMRUtM^fG*uT&H>a@wN9-yaCI6AlIKlD`gkM#-;=FdPNQF}=VnE1Q|- zE_z@f&U<5`z4FHuH#5v8VheOk1+`fVOAl;BmiF? zd>Pl@I5}3@r0E1Q#Hqg?bu+9*8tR29XdECkG?~!XI~h*I{(4BN4!42W8^x6(>?eMf zeeW@KOusB*$Rbm5T-qa%hX&~d=1SZEX)kwu-@w*Jhe^F-wL+GB^T_Mbv7dEJ04V46 z)Zd+4LAc*hAT6fc_r~p}P;R8#2CbBI14?|IMp#qVR(OCgr$^M%fzg@2uDnqSIW2y(-RMKa*kPO}7lnaspcpwFD$ z)(M;~f6;^8Tb{-i+jNO@-)51Pqv30n;)9$KH{v;(wiBh_C$0GK_gSr5K{(&jmr5Q+ zAu8rheUWWdEBXGK>U5+ZIh+{ln8!!g@J2f#(c`FkTTLMkgG)~L=!F8F&V6Ug;R{n8XHJ*WEDKX7CA&p(?XFg_g zcvEX}5Zr+8PBgA+k}ew2=ZWCbVi0- zd7o^S%8B0f-D<50g%10D0eO{SgefC;Mrdc^MlwC;BCI-7CnF~E&k4<1OvNX&(6~s( zmmE9S!x~LIVx>00Qr)`j(&MnUUhjUhgS>Ilm@etqbAB?8u>GWZxl*Gs&F>CB7b*RN z$SrlB*WNx_2aTPpq0^76*P{X~7-$9)-#Z!Htlg^F5+BL%r_e7eGjTcxWTpEDOKnw0 zR|COEg(*n4(kZDsN8(LVPlSs?{7nl8>a!8!Qa!~6bq4KXHG0)S<#GENPg3N;UXf?+ z4@Bac({%DR-avGp($YjdE>kCu#cH1WDf0%`q-6zFwlJZC3Svv2pzhqVY+{SbWg zzP{qP9j^w@4XNBmj!R*cU@R*&9?HsQ14;5tiAv9xP zusUg;g>ce5UfctUF8gH7CfsDzwc+rX@i(?M{3;s;Y4Y82ik-XksEn_xR<omKy)(bSx*9ic`Q6a&MaEK7OEsdrhu$vrSkSGjytf z;tm&$^Q4UEop7HQDuu@YeyRr*(fzrdcRc+|(TU?bfWU247L1lL)_=n2m|l69r0Jvi zEB+o57~bS5`J=~lN^GRU-J{-$znS>!+dE)ueZQf71MCL8Ss&idQjd3>yY)xgA63MK z3ciqzYCo@@=(#4TpeJCHcfvCuvsd{}>7VDCjhwYqyX?JF0fuMaEOcyf6!K}bOk8lT zG0gi%XH!RDp|>`kG9z&rz@p*2u0Z1Uft(n1mP$>#@D*xL;C6Gsky<({4Y_aT4eZl( zzrr;eS)E_7*_1O3mtR$LinEW0dbK9+ORA~EmKs9+j!H{<+IChdc(i8)zkYldtyf>J z+u-OO2>CeY!MV8`DoasJI}nzLSp~c8RqkE(NCAd7W~O{MxmvlEw0GSKAg3e`<|XTZ z$}tgi=GB`1Dn*cu$<`PB@=!?Gqo!s567DV2II;3C8pEhk_zmvQuFjuN z>>5iAs+A?>77`SI5?U)?CmzPo<$iGX*E^m`1+HMu+oi;4kg=R$Jf_`gHQ&T~4}6w; zHD8?8p@7U>iI*v&Mf>6YQs+W#V0%8xNut(c%;RwV{6E1X%qjPrbPfPhdGrF#sIAH< ze8n8uym5>?f|uWpvFf_rYDo5geQ>U!p;6K`A*pOm3#dP^TSzvxkmiT_}8s)co}cqbHPVLhXD1xo+?Ez9}RU3y~_D*qG$F! zq9f7|mf5rSB+FtcxeZ)8Vs_>9%GSd&+4^Ay zZTsd7NJN8|pAIrdo1Sr8B4&HKse0w5 zPa3C4ad;dO<%Ssg#2C>H1?n!Qt$!x zl8>aAf#CcQbg0KJ{jvV4VEyJw{{7uxNXIXqxJ?7!Q0Afbh&$L_i8YjQn5}m*F2lLL zSJGF?3Xj>RrRgayB*dg79xYGw%p}&L`#!k3ri@X_r-fT}&0kjV;U!C67NmUXZ+yZw zyNIYu8*;+{Y97~_-j5eAO}Y*?WH)7IBtR1LYcshvgTAbR)F-*G+^cxew4WBZJl48! zcrSY8*{KJuia6|R@3iL=Q`=*yz=hhkhROkQ`5RRZ`L}u*%dH+5+u-n=m{XsmCm3WC z;=DRom-OzZK*e0722Vw9s9$;OPj{9K zJp6NTE^>WioK9_Spj&{=%TH17O;-b^Ad<Zr z!+Jc2?WIMA$}nK!hEFf{T{wx~`_aJci)J5tLE*SrGaEbp?WhBIXGX3odE2-T$5Rlb z^F?SMF@{knz&UUCeSo}jHE%3cZLGhDUNt=_(A4qc_~`6k#jJJX?2875S%Bw3p1k9A zDdDSVb}zLOa`;!3qdpXm_(e|7fWu%YR1!P-?LiEJS;^br>XskL|D5?NS0FtIj1THk zk#9Dc^J;f1a(DrE2kVhUhHPb=K7r^@66u>mb@El-mg(!@C3y=+F5|ve62Z%kNgQS3 zp-F8TJhc~RC?a|pzCe!xAy&X+5x-!5&J`e39R+a7WMx-dh)4F1&< z8~0+f-{e>7w|L(1*N>Okt;5kC74Dydtuh?fvxYMs#HsjMPXJJ9`mO}*pEQCVgw-!4 z)onXh>z6@WiRz<|Cw|r^or#c%o@Q|c*GKxqTmn?hLIAfh-t=bu&`W;^9fRMj2fC{T z>J*=v{uHQ`bu-9O9urFa#@qo7LGp1OJ*YS<`x}GPZ>)54UNUlx&m{)=aaD8||gQt1h z^K6hIB$D)n>=BQRH!9f4G8FZC*2~l5_2b*iig}|E&|g2kUzAtfW4jWnpkJW)#M!w|)Wrk1-L#xaPiOlV^{D$x zZJYv+DRqc<7d8-v?JncK)kzqRQG!t5vsVxDWwcgbmU6h+V{Fc!%hZ>GXs@mD0&JRR z?K0SA_ZR^a%v>4JYJr_50hwk{(BM4{ya-Cke8}N50x(_1QNzI5S&n{{&r#DDrTV1j zJX{=<@1GIpr;&JmV6-3C&O!`n*%w&7O7jIVj21D z_@)4mtZ#GzN%w0{Ds{f*8K%?K%jcpYC_etI$|ul1BB7Dsf;M;!bwA9!7#QxZT&D1DmCy@|7azUWX ziAeTLElS&{xk|r5ykU!)HONRY@=lsR@F|2QSP1DHa{h9gxGMlY*zi+geSOEpWD}Ta z%@>?IQv zPHr40mtF0vVclb<1V1Crf$>PRA5+UXG!e=ayI}L=^hkfU21GD|&{7Ye!@$E)D&YNz zBs}$3e~pPAboR=-Ps^y=o-?*LrKc%ZoE#`x#CCmya#)Ryk9=-;>GbPIM+!Q!F6pv2 z5!<8ACVLkohj!PAqT0SZ%1mM8VfnQJdZ-kDW1A&&DtWdg562Tv^Y-J{35u%});;YK z2lhB2z|us3s<_h&ey6J29g>s~ZG6bj3w}EQ`5@Y>VP9qv!F9`Zia|-)q)}ru72DEK_xmidFV;U^9xsUmtt!?UdcgXvT*x zl}%lFJD|A}sie!Cp45|y3Ey8)a{iCZvEJsHGiyLa(X1L$U-z-@8M!rY-I>%* zrx!}{)j)~QXD0>ysuQ-G4Wlx*$vQUI&TJ7vH&-dIINg5u!hA4Cn%^^T!w!oAEb6t7 z`aY-iY6d6YNQLZPA3l2-OFt_zL{r7TmdHgOa<+ef2}eM)EanTPRD?p07{ko(%>lwy z_DCIK5}X-`nE*AZ678d0o(Dz2R+X?^G-hI1moavCdevh6Zovt&9;)dv%0J;lko*O_ zFIL`NM=Y*wXtqu6>PA*2r!U)V9Fo!OKdR4} zxWBTAQ8&^oVWC{gihMF{>Qhfes4Vr(NSV)H8Vm{>Z_VB^CLz%ynA1u^xpNOI2X?j3 zzpOOqTZX(ARTBew`wL6iUl=N_X3XUSEFAjeE?L-$gw4;7!C6NIu;r%>i_;kW7Y3Y3|K&?D}Oe!;Bv`4 zd;2D|(thBjATU{8%rY}$^M8k#WAu_9Pox-Q+guYZa)n1g18h3j`()v- zHM8tUu_z-y8(RCb%~Z5^RpRHp6_>tNlY&O}1?-*nFTG&;)5At|Tt z`8%i*JGRIV!l;Xwq~%28nq6edrVdS8={>6Em?>blqrpaFuxF2~Z9ZWF0HCnNT*zBL zw0vOTGzD;nyhJ4#_DB8I%J;}4P(cniCvjtCAV;>Qu3rG}kyJFZV&Y)ka(2$#_2?v! z#NY7@D_-rnfA-m3#AT(3{6&KBfRbnr%5TgRTnhvb`MuV4aZXKc*$6xo+=^2y&C~Djw=uJ*moPPWw4WO;vJ!vN#(1<*fEs4jSqlx^(6ZX;f zwe2Ut>p%J|E4@8Oo=7uSu zQiBkV=U4ihE>bi2p+%#eOCZ2z*u*Ks@(B%%dC(UE6^K;JYBe}f5_%Ps#VR8Y;>nTmg!RMlz%0F8I1!4p5I9C5Kt+|+N3S1iy# z^4<^K*3@Rj&jYLA!?x6Kb#II>M^4QKZ+5%7+X%zk>2R4p)lYrQtTQ~Ti(qOwz)t^> zd>MC}0NGcuKtgqqB2& zxM+E8TG~T}-1Gz8QZ4NX5TW{kzh9BTn98Y7qmu|$BX_S_!xY_)rqUuS9lRvv#P5XC zr&@LDd1xhL#OAnoOy7hDFMe!}0B_9qf7?z;i?pg7F|!R)HQK1G4HPSqA%3%%$`j(0 za{8nT83EsoTdbdlCc$|Xt6^m_0pTG;Q)Q#L#Y749Emy85x4B9%u7)!1t2(dn4c4;G zS`WLk7*Ad8)s9e!m$@9gn90Y205hemR?p$RZ>rK;R7Bh7w>;Y{UycRmHXD{1)+-p% z=YN|$;NJXvLx9-ZZ}JXQQ>88y4yESa|MSh})*XqD`?HygfMm$ts`!fbwM0 z(2mI(_s!kYm4ZB&dQTnOrT!`%-tI30bbWL6P1_BF^ss^k=FqoQD+j`Cz-1=D9KZLy zeAEe1oLyAdzm548k+gl z3NrGi?U8}jQPyO7-R<%6U`*(Bu}#f5;1i)mmCNcSxb-9!ug7Zmr&>R#u&qB{Q`|s1 zLt=B2AwMOtn^&&7EZgvadF7hWxg_Yu=k^a&+>s>7-@{pq-4NovXsoS~Oo+D1ovZeo zrIlMD5$Jyr1!pFODY-hQ2U^s%*gVE@c-$BgY^9hcRfg)-Z-VGef(5BWAw{vE4y;*R zZs4$+|KOcsq@fpAFIgyAOY6e20QS!^yOd$L0)=jB%-0}1G%%io^dFpwD9J%fELVc} zNQ?8?deoAP81ERewG%aYJZZ(knbhtD8Mak*g2v6IV?REGX`uL75%N)5`j(jMyu+=| ziePZo*v?*ml1b&(>~c}tt>v18CJO zRK0PahpSzE=cK>7vR!q_uZ5AD7}LG?>MXeqrXub-aT|D%W6s$6u%#oYSm2d#Cnvw0 zjpv*^TWmMPx#5Gnq4P@FUQ?vtfJ~Or<>=?|G!ofE`=m~{df=M<3|K?TDTn{yUSGMs zN2}Hg=c?AJdG|YKYU97qY1@zq`@pEfnrH%umXt z6e~c>fPWHUqn^(OJAE-@`oJ zMm;hq{{oLLJ|JI(jI~uEb;C7NC{`B8_9!O?IK(4E;o@KKjHwj8JjdD@N?<ECOyVxQ)Ig;n z(dzUoF2jVAoLn19gM7Qezx|DdCBpZbv^~CkzTUh_c+B(l_45~g{7wfzI^K$0{3|_; z6k8?lM!tS5hD&2{S3iXzqn6#s5Nw)DT zIg>TxIp|BF@WYm(or4;t*p9#};g1j4d=$_{Y7K9a9_kofTqJDW6AEG-a7jh@HdRm1 z&BQje=AboFu$UyJ{2HNOpI23<{o;)e@|1c|5{7h*TUwX9%Ux2!hz}T3H^l#ZtQwOJ z6H?hzT>)=r+xZU1KWT-k*Bd-x4<$6jZWeBzNG=$Pn$W_~Z zw%@sVD&;RGVJZhRy`3Hoy&=>y{uZ6V8!EwDLZ}e2|8nqifq`9ZX|`eNo)V8k#+Pq& z{3?wf-zkRCe7R?rqARPK`rS{GPT^Aq;dUTj1`4ik6uNw9WGZmI^eY{kdYsh07n$Jk zVHrVJb~8?)6pL{)q9gs)Gl4+r(pf2VGyc)#23EUdQJR{7x?y;h`iw?=nzwQMLRoR? zm??QL{mEk?t0LzKO~Zml5uE~thJ4RkuDsz=MsjM-bzIgC(r@u<2ot zd~Ncln#!05RH1zDF?95~MGn)(Efm@K5dL1bSrUscb3#euRYO;CTaK~$lbZGNKp|GI zhoi#T3o~z^FFzj%Ru|5PgI4z^)-bnJjMy^{{L;<)kx^W6%`>xL-^G-l*baHItj5f_zxZKZjn_^t8g47W1Dul^cn1xwyFO2_~-qBq0Uh@k0 zX=2Nje9)1*!XJONq)35%2`w5K{h18>Q~!pLbF`yhj*BAzV0;qz zs+$bjxY_XABE0h!48iA;TE6mEgR~vBw@e++J445v#uTO)1}=b%>|!^Ne|gq-31klw zo!qw#XuZwuqME4!u`=!m2mJ`NP#>@a<&siY5BrS(VeJIpss0^6?*l{-uEl;kHg|iD6Vo z4a}W2NSu{{S4o8=9XDNkQ)w2sJRoy+m5xBR&XU9TvBu|8r^*@?6DX|I;F`v2n^!ro z5yiDNZ}^p9Yx3yE*59O~wr3K~v??>H4`2utB+h-@3xTc-^mn=W?#8p)lPYRWSFYjs zmQ&IkFYhqaX#1Y?C~gIqScBA=f6`7|)Cd1nS3ShbyFk}_*$Zk&>F^RpfXIM?7G(}4Lb zrB3?!evdH2CTr_l;E@Iip@CS%F!JEy^f3A>$-c+hDiIbhcI)^_;3JPA)J1!VRsM9wI04q2iPjaE)&pX%%v|W^T#!h7k*MbOlUSbE zM1%`+QvPp>mf)X6YO&?;eJXGNTj$9VxXHWmOnJ%x-KY#Bg(RhqNCto)oo1Nne?XvXmm7Q@etr`68HWqiP?sbwSRyL@X zAmGY43*A@dg2ZXEpAQ?6pg(7@v>FdH(RiODBIkNgk)Hl(Zu`O$0w5`W3yr{0(RPT3 z7nL+gNK@;{L`G9QIm5UGn}%fd=EPRL zzb~Y;ypU_?S2G$-)MfQwbay*(oAMIf)lffn|K*YmHmjll(un;v8^JA;?!dlBH|?T< z>tBWg6Xyd@nSKm(2b8Oft2}#ltha7Jaw8FYk5f3K61(Y8N=9ldGb+D*{H;je3`KjBe z?6i+0jc?Zn)LaqFU6W_{QK8lpje4IF->qS6V+U(|mw zMJ)xhbvex}f)?$aNFI+8wr;&qYts0U{GAMXH#MLH-$_~G!K%e*w>u8Vz*q_a~v$iU=(NR9P zDsA)4gElMkbJb9~$SUw*pNFm%-tA&9?{uCN%reI%Nc%jdY5#({z{qdLInCSek!_E7 zA0iKanmeiGYp>jM)<7ScCzWb%c3kWg@!pi77WR&`*KZEFP+pcuG|V3_Byb=OIEefv>%KgYcvh=%LabQM;yays_i@PexOL14sfe=ut}_0!%IZud z=p-)qo7g`Od0UVCLaPG3S|~)TV(JeHiapLd>4KdWoDi_5eJABD{+0ZX226bGmS3f0 zW+uWY6OgHf#o)=hsq=r=fu?MFN2q{2rg(a~(g>=Ie}^$NG&T+j^zc{6S}TO^qK-Du zDx?dXU7G2l84o3_>2Ua=4FF}52nUMETIN%5k)c(t;!K>Lat$DIfGPEJ z0kPkF@+@+lW?-2BFtUq_%EtWNdVl0x=B(AtM*4A5i_kC4drU4u>6cnh@vRy(cAd?z zGZHKbdv;2)38IjG|3H)cVRrebh0MzwX9brOsfCmtvv3ih^-9VqX6q{szVf_2{_Ye2$fw37I{eBWsilAX)MQJY?%mGK!~ki~@J`R7%nxgr5B zhyl$}r*ZES42I8RWc!t(DibfXj>L1e!?Qb2kh973mkd4lgz+oS;^!^I4BBLs(a(w9`e~bGd*cZLg+ojWTgzDr3i^{~7P+ zFm>u<%$Y#z4k;!IA}?EB@fEEc(}821)V|2sHKg>(pjnw1r~pdi^ShY+<<>+iS@Y0Q zm4Z68d%2d_xevr12aV_&E2@DqhX=(;XsKT53>(SdTsK7(Ce2G6|3GUld_qTUI|P5A zh;xQ4G;%+_9@fpY}?S$ul|VZ%Pu$uO9@5~ z7~;k<7UoowVuK9M{TL2Eh^YK$&7`sI$zkS%O4)Df1$vxhSt?{Pk#af_dAUd*#DMk5 z3MxxXhz_dyMCT~vCX*=CK{*dpQj11{R&eLx57+_MB>y2*&ubX%1?N@|Sd3=b>DzD+ za53Q_X8ui@;io(AFWuaXw2>#{8kbP56#WskS7t3ac*uS}o??-O3o2XO4--9;*cq_# z9onDXe%MxF8kTzDPvyJUFb!$EB(T8s*iF8qbRwSQCQpMsd72^Nh-iFz{qKC6H z+Fv=O%<-g-M<2<4U-OZ2@mT%WEw7*oskGu^M;HQ)kQaFb$ZY#p&dDiCT+-C9M9^Ia zI87ZW{dU4{K7qcyxn(gNLnzh1_ahn_)!%>r$Es+i?);6!P=EcO{K(0FM*n~Di4}vl z!oV{ww{|ise>klz`@iyS`@_AT%!>9Nkkh8?f1+P5cgFQ$r!D1;XK*gdzgdGCck<(j zZhc{+2d(5#plFcC=}aCjL!U3d^JIAiGNz1H{Wrf*`P8xguv*j+0Z;eObTshuwKVG0 zcFNR)BBK72cVKAa0a3bkYupDk&%?(Rx9Xk?Hy<^83rIGbl`WgLW4pxpk0x2($C!9U z2dr+g@^vWV9ax1DNM<|b)PBJK*6$Or`4?z+pFu>6N;_Pg5&4`ZeAY?r$MfI%CF9GB zV>7mVZ0|TCE@zy1k(XpD)OsVXU zV14sWmQL>syT&fUmyD8{pl2;X~Wd zwENKO`rxGQH#elTOC*hy|E%UBbX6Yk!4k9K2MJK<~-LD1b`r(?0%Ui6+$neNC)Wz z2qd)72_=NkLQZ_ubJue|-Mj8O^EES**=6>B&u{kLBM_Z@!6p#|{ujo@*afDOgJ65%S#e`p2W~R-o4&CYfspbDuPM;@Ek{x%<6OqAHnw`Dj1nX~&x%Tj@|e(NUYRuS3WFjZXYkQkSV3 z)A-Z{K8YUXK8MD~vt6AtU#gheUL(kq&Q-)XB{1W~H%n<5ki2#t-0l1LsEU>n@Q`P_ zgkZ6<%-CEk$<1rGDaJ{h4iFnuyAc%>i+4<+JE&_bVg~UzT=z=skxQw3hWB2KrBYOQ zV7xtNBC6w~;=4aPw4IGS)X^xJx(^^90hfJ=399I6L0j^RX>smJj)ZcBXk>h)8k3;J zO+&4P6CbJxxJ{SU-Whs*4mQ-SmV4vma>nEN#Bx;UYTv1p90^T~BsDtO zS4lmxITkZnp_AB^+E7^W&=#`xC9&fprT#;x_IY-!qy(Qpx#B}1s;0}ch&rZt>Zh&v zsL^|)n&B0|{oW6@i7owG3M7um(L>p!8^A7$bc$PPlss8xdwMRS*Xct|)xO^xA`E@V z1K%F~M38Y*l@YkWE?hphi5O3JI9@UH8a?C%uk6R0?T9&alvTX8C7$e3qXN`yL%u@4ZQC4#3;#WtY_<#tJ)4k;SE*G4=*Dg+Merz}}YgxM3Dk2`rcpOwL<~t3Z96Qli_x4o# zSiCkMRtdQ4fQcYNC4}mBEXculu-e%jmBTzMi8!!~EKosCs$^77fZ@gJS&l_x;d4{U zY@DmYa}o7>bQfwnfCgC^(HxOQW5Zc$dse@>ezigT9W;|j) zirH@CE-!3xUix6H_Rjd!y5UtffE#*k&(9>JKWBdH#U*x!7%77Q0!|MDd9x}u-|sN# z7E`l$5w_(Ao)7Bn+i&+oaA!M(Kz-@i$8LLgcJmZ&q`k0djp(Y6AU*#HDLI;f(7`MqN3cg7xewkrRjsKq3Q=R($L80vASHvIV9_!Aeutcs2&gj+j7(rC+v zEmmHHN=)#039GXc1fUR zXREGj@k*Dg&OnFw9-p2I^$qf3Zkkv@(+-VdG2gb*_?nzIcKEmb*@leXRXcdE zh6jJ9`H&yYD(=k+Hhuh+3op{r*!h4ZblhKvpY=qvGOh)UG!E1=G5DhPp^E}sK){s1IhQ6$gps7L^&0~>VCCI$JB(z&0iOZN zs%jut{b+dwc}pPC>=+32mfj2d#|NKi#pyGS zyagV|e4M(02zxL>yg;MDsO69nTcqtzlRIevxttb8VgznGD?l_HP7|!_ulQbJ997!Y z4pMU|v~gg=%l`y5F}_^a;y|2!2$h~}+)B%6QtGOH+lMM883ig0vae!|zl`WRPC`jR(u@f2+IJo^ zJ{2>Xp2Ye&OirpY`9A?W?PwL09gPL*9_6gU(ma_N=k$(y6=uKCQ_~Tt z{2dWHvcXIfkZTqYlX-KBSV?;g{e;cTz)wW-dWPCd1iwp3YXG+1X7gN&72cy}poVbz z{gZ&yMLI7YXSfBm-p#!?KR1GKQD1OPs#y(>+frO&*%`zfRWCu4{Ue3MnY%D9*&K6^ zn`)e8qT;OZ2j%FU6NICoCGU$+?dLx^*ak^^#$b3$m2XnEHxgXq#T>vHfc0JsSbuM9 zi;b%mO|zT60l9@rMzY3Pcqkz?^y}~q^B28{b%bkFzl%KSf+MI1M5~lE@C#y(v|mnp zF8p#g4RB+{WokI(KwJp!RQtErraSi@Uj_r2Xfh76FybJPWiO{| zUhz_yFOIxD+>NBKz1bil__n4b&b~Z$8<#Kx46Q{zfP>lS0|(e`>|WZ?;aO zv8PbXvME5BMlp68q3N?Q6tn%+T+Al+v32_yW`n@d;V65q+$-Xkx5!K=Mt0~5%_9dY zN3dM#XZnl~>FdG=g{b{k7bBDVHJhpGFXHoFo^H5UA*Zf19~s7n1$r*3&Nca&@0LFI{}x3753%HP!;|V#ddmN)NG~*X$k*?VvXE z0{PkT4*?;m9i5}~*+sDqudTb?SqkuyZ8mS}q+1JnTSQhvFTFwgX#oABFMqledQw;L zQeik_N@LfV)3j@^{l8r-xE$y8#^plaWm`_u0AoWUcG*kZBByq7<^|PWEU)IPv%Dhh z>?E=*FC7ibkUu=81DJgb_XCCB;EPcGy4Gr1%Ex+5SHNbXrp`i~6Wpx@l$25LW*bpD zsRZYJBdxfV-n$fk#?J(`nBH|J>I{&-pG15bHq-$^Bs1T~{5xTG`K5c+_iLoKpo zhBTO(SQ4PQsptQ8A*pgKboSMQI_(>oOQR}>`X#!XG6-#5if4%SuF9cs4^pG=HTnTM z*uI~CZHlpZlEWWV(;MzzPPrJC`i4EqN)zo%!8=-hQf z#*FkHAHJI{yJ?4xj1@s_WrG+9r0D7D2$Z_d%>rl*u!4dCUC(h0L-ZNxm*hgBlKkcC zap6B@$|=vp!x*Mq!YRB2-?f;-2-f?Up>e!Qyd9g+IJ8(?*$F&!&bf!;<+R2Dy{R!n zdb^P}UT3eLgAK2jKZ1b)MXvbF5xmRu03|hX6vC>$(4Lo5pbD=71*^&)P3cw|yMcUe zoE{34OPD^q*1*N4*eY<3fS%#PEpIW_CG;f0orh9&ixed$I3HYnO*#O~=Nlqb3{VNU zsmw(yFZjWWY0DT!=7K3&r>G_8!h4pSEnmKo;7 z@V6B+P44(Eba~5=4+%Yja*wtnJl2V(QjewozuhOqG;{)Rsx5YFZz0tAR-$J-=4#Rr9pnA=%Rde3u*jUu_4oF*L>E)Y-GvF+5QtmFp=_=^Il&>ZQodl(6CC2AoR!VdReKBzR!}lNsDIn zX13hz?Kx2Qb(K_nKLJsv31QFjGbydIx*H|dqO{n-z}y=FBKGisZVz!AcF|Pz>x=ga1;*rpy;?wEn?Dm`JwFSrI5yDb zJ32>^am?@8+b_XQclI4Qx5vtLM3`*k4la8cV7|&;6=xdmHi_m4oeO@(28H}W^ZFI^ zm9kFYu8W;ymaita?1eZ#dMB8e9?r>)E%?!D%4z)I6(IO&>kU1*V-K(Duy^n;e-!kM zhL3v~D?fBCwg0gd;6KoehW%;S62objJji-;R^pIxGBKZ-T_kIgMA0xO+YlW z^?DNA?_1VQ-PKb+eo|4KU_C5w?vH(H_xDaDb$gf{juN+_MGDK5r)kHXy5pF6x$|RM zze){f=(w1UFQk@22HlrwUwtvTFid#g|zv88vKvg|KdRM>6)Rf>=F8^ihM$FrsM(A zlc=;@T=>dgPt=cx7zsU!pBzAq@ak;03X5$6J(QW-_W)q@h=`m zLpDFEY~WtK_Oue?J&ho>SX=8#bI0_n3k0-D%hf5%K|aD!wiuEJ=}EyD}39*_EIihn^7X(){=0 ztGCCf1jb!~Q+41Ql)Xg^rNlX!AIMi@ZxR!CrzPI=M-i5zj--nAZlKBh4bvN|D(P@rfB^{4ZXz2 zG`fYO{}yiXD}bElu}g;rmptv)49znU2kuh~Q6^y(55xa0WoPw3SnV~Y=+4=>%-2H8 z$ym{e;yeH56wNvGV_;3oadBjXHlt zMFf2G3_hit@gJ@v6f}PKouFn`|7!Au(Boq+=l7m{x&^xKK8v-A=>N=e&*Sg4!sXls z?-+J=q2cokwR2f>B?XEs?ecwpE8S$=J~AU(jtgdWou>!Smgmp3IraZ{?0iT4g_gFC zpj#t~Z^yKe03jXDLQXq&hAE{#F!%=}HZ2)NT{;f)kAb-Dh%jf~hkr}h%ui4)GarDK zg$K$m^yB=c?wM~x6~3yjzwHQh;XKW!M&M@7#QR?5)>XD_27G{6!4F4=`B80K{W5=* z`Rj|h{PvEl{HW5e)*o$I4c28NG#RdzS-b9{&`PAgG`y9+A!-}ugY=H+VUsk%+>lh= zrOW$#Db8_VAGF1Y3aCU5-qvF@PlHI9;u}fkF9ij=_f~1zlT?#QXZvu2pRE`O9P895F$E z|3-fN@8W3gv;E}>joII^G#R$%|KR^LF?4?=^`Z2?%gJkq{$&A8*Z=jTraROBV7p@} zH4l_9DP}RxS9-}sDRmFqpxju|Rr<$&XOzDeYPKRXJNCS~E)vL*VK3g55L zP&4gXBlonr+s2^DsN(Kmvut27+EJ%CeUk4IalIk1R;`;j`l@ZWE-%KH{(QN%=?Z~h zru&07Z)lYT=#KAY8?1SPO)F<-k>{d^Kj7lHztKcB8wC%GxCXaJPnt;4&@4%nxu?b# zmz^_xw=Sba*fd!VP!t8k7k8gC{oXKD=}BQ+UKx9Dy8-Gp>&Ea2Jw))OlY0WbZ~yqT zRq8fezr9hswHc72{jAJQz|r)VtYjSryJR)!VvAKjcM_O7=9PBVVH0^m zc4>BUBbbwky8$&F?%-+5lEZK>Fm6&0`WaHPWS7k8-Bt?y=uIb#yJZ6Y{1GM7BV}%s zxEhrxbeQ;ZVfUnAI@1e6URp?V`dzPRF*Rxak#PkdZ;rVy#O(5w;x+&#`8AhyyiPnOqh>LC!$ zG)p0!Y!kSZcu@CLiL@S?)vVM|ms@4zx+S!BwHXMNB&b?hYR|m;UN~38J-HjV5n+!6 z&qVSPTX~dg6Q=XQ|@l%`ICVYmnB$)$!jvZp*^Vl#Z%FI`m6Go;Ph=y6@QO)tIlO;cqdaDpkk~`Yo zBgx%S{i_Kt|Iv5J88Fv^tT0eu@D=txm}GID?c9}wW5X+G_GiEsrg|&P1M%F=(@L6EwOxZZSodj_(h8nFp4AHm(N20u>Dk1Y?9Cb%vO{e z<&U(>O(~I+yItTv*Zvh$p&NI=bq_ExPcjMNDToe=(D}3%VyoR`zOJJ`G8>rlWV)&j zSK$&y)htDbj5e#(2ujJ+D@WFAQCiTFH-Se5TG0$h6o1JoS?3O-EuY(MSzkPvi{&{j z!-Y=kqPnW`Fjd1lE&%%DdKF2eqpUjLZiCukVT>-P5@2R;51pI6S~geeltgmtWgm%P zbKVqzraMmW9q~)dan1>rti4+#WL(^&-i4K3^m>ZJ+gzO3ZocDIn9=ug5QL>otZkHo z5jH&3F;OaD&~8ITSl8o;A<*K@9X+l#L;tofMJ?^Dho+w|iytrb(=FFHW?WtwDU#tq zU5c;>Jqs(tNC_CDU|rS}a2CG21+TN7apdHds(@1$l^|`Gw`_Bf-u$fp>fcy)ZeFR0 zl%Tf0;nTopbl|cax^%h0phgH40|hTTkzmaXD+p3S25Dnu7(|cUtPT9hjl|C0>U0K!|ds zaWBJyC)s=M6B@>Rzc{`^pj>|QK?xSPZ$L_!lAc^b-fKs%xEaNm6M4IaWn-_3^I%+E z-BLt?TNXzLe_q`f17;-aQl*6%?wl(d`NB%)yy@&jqcmZWjo22a79VWrvnEy-n}w6y z?(5`@yHA{dbDQkA${9Bj82FSa&|h9ab;Y)8_eODHNy$Qp#gs{r{e^mdmCN%LsPXHp zC-Nh5*nFr7Bedbi+LJcR*i-0cKZ4J=muQthGwO$XJsf3g60D6G)G$ggeUNh?NU+;<&yv~ylB9pLP$58BnU=5#zy!|#5*l*1j|HPSHP z;;opZaLdvs-F6+#nA40sh)!8OdZv~q%UxlzE8M_N^Pz4tCp16rOS~Fq{O<0M^Ml7G zyz6CJ23{$a)0PZVJ(s_W^Nm}AVuKG|AJ%Dn%5~8bbR3RgYzQZ2B#biG!% zSx|Pe%q3#WtoHHg#XEyAvyUc4Vtg`GDHzJYq`$8^L-BbyL!Ncu>u8t?CLa z%9)17`O82$y}_QbSvY%L&KcN+^w`0!xo4-84rQI-Rg+g>%=8`28dHJb&=X;Hr%j_| z8bhLw4(bMMiflr;aJm(fHyzi4#-5ufhQ)$;iNYat?6rc;f(iFFy!CgDUa+2E869dI z!1zv*f(ArOL+IEdq(J(LQl*wX(~oRWULH|5AQ$$g!)Lapu`3cRLv=#rYBnpX&tccM zH%v}nLpPLXk*V`V>Y}t#3%e8#h9Id;m%FuBC5U7Q` z`RX&XM}CRP$@Z8Upr@zd1V#hrrlnmFY>kQ8*c4_@rhLu$pp-sy1Arg*@|)ux%$O)N zzG-vR>Qv{Bun?Cd1Mv}8>ALhd8rETFr@sFHWgbK&mKw>}2x_O-$rH@c-W!QYe)BCqijo<9RqmjrU6d@0?Y-^IDQ>=p`8H|sPNW3?d6eAL{4CUTF0-Z7EDD9H zPG(TC(H^|59w4tecEeDV%pu{WH$RV#t2mqRgj1Y<`}pFFoT^AO_6!V^g`Fe2n z5CAjjwhBubFDNREqbjY8;C{G*^Ge*s0v`}2E>28GAI|0*Sefn(4J4H*@d-VB(9b@|URYA@ZQ~hvIRgySOX6Cbn>;ob z8{R43P3j`$$3PaNs&QqZe=9r$`5H&=?Q zA8JKq`}da}SCd^mdR#XL4+5^qN*gIIj4-Cw2DY`A-qvcRc*oRKnt z4@Q(N8LeIy>xRqLq5*zrLZcuN+~S-bi1tvToL!vpij6+aB}!@IkjaZ+;?%MAd;dPQ z)|pgHqSjiingpz@V;G1vR()M_2HWY>?*wj||MPa39FIEoxFt^znV?Q})y;#LVz#7r zdQK)5DxPO0zOr%yc$>rIx`fhJOtjdtwL`z(0aQBnbbJwfw=#LB^2#sYyccv=N^@N& z(Y#)C=E2ZP`4Z|r)sTPi$x9Xmr6CHxB=71!D5`Uf=!`DQeS-np4X);+M2I1@8~cJn z^$o!m9fej+$|E^rK!`2R)}%-^1c zjhOhXV1FScG{o&PTx(lB=j?TdZZ<(w;|FdkKFp<@NI$O8-)BWH$Qv-GHdps9@z3oSB2cceAUdITp0ihyTmn7Wj=1(V2CbH3m)zi> z`iDaqeWqSU8GP-^^!MnjaFDDGYCI6LJcc+!yqW+5&^_7ZUYt*&rnw zC)>wDuei+a)U=_-)xUZPwz0bX?*1Vy#9Yn%o?C=S?RJHYemt;`GGROPIjVKR5dx=X zoCe}r-gLGD9Szu>=kBbSs8_HvmLOJx$apPoqBhWD4y3N8fObc2;)q&<`%l{y1PbIz15lR7Fno_p};7 zldh}BZV!c56-80nkjvr*6@e8VtIz((>;<6rMrA!Sk@{p6FjCM6k+8J#4t`v22R2G| zeK3@NI5YoS3K>tc&0jvy*9qg5NNA8@m9*9_Ap58}z{QBZ)FR+RW_jA?E2d!PFJNZQ z-~W4mo5^n=piwsJu3@)UZWz^NfBYlDUiJMjo#Lyu6kd44L*Is)dfDHdFAGR$3zdBx zI}p^@vxS6KX~M{vBd(e)gHbR49@>xOtW)?%(73jb}0hUivU)TNodpzH3FW zSU*&la$cgw6x}0qDlvan6r5zFTDvb7e2G+i4ft5v4=8 z?|qSG{(ZMVvm`ezR6N$x6ZgIKRl(>g(I0bxy|!i7-wrJ#D4@0FpYWGZFVFF48TK`E zezWclTeR~R)JY}u%km)K3k(Icl`~|+5-Cw94utn+S1zXeSMZ>(@P!ZrwljYvuarr#};jYe;(7UQ8Ih=Qa`4FPua}q ziLO~^Z6S-kDhFD}ntCW47W0GK8LDSjBs#tq-hBG5itWFZTcLH$ zsmc|kDDsy`wu%85ByxzTJe?r1MO;jul^oL^Xv{x*R_&+-Zg{;*hhRfo$xgEQ&sdIVjG8s*o(MXfM?C< y+4<0gvp}6m_dny(s%ZJ_Ywx)Pp=5X*j~q_u52PGNw)Xz|_2qN5XT?w7`u`V*Ilnjn literal 0 HcmV?d00001 diff --git a/samples/ticket-classification/README.md b/samples/ticket-classification/README.md index 72bc7b865..80742bff9 100644 --- a/samples/ticket-classification/README.md +++ b/samples/ticket-classification/README.md @@ -94,6 +94,26 @@ cd uipath-langchain-python/samples/ticket-classification The Ticket Classification Agent utilizes HITL (Human In The Loop) technology, allowing the system to incorporate feedback directly from supervisory personnel. We'll leverage UiPath [Action Center](https://docs.uipath.com/action-center/automation-suite/2023.4/user-guide/introduction) for this functionality. +There are two ways to configure the escalation app: + +#### Option A: Use App Bindings (recommended — no code changes required) + +After publishing the agent package, you can override the escalation app directly from the **Package Requirements** tab in UiPath, without modifying any code. This is made possible by the `bindings.json` file included in this sample. + +![app-binding-package-requirements](../../docs/sample_images/ticket-classification/app-binding-package-requirements.png) + +In the **Package Requirements** tab, select the app you want to use as the escalation app. The app you bind **must respect the following contract** expected by the agent code: + +- **Inputs:** + - `AgentOutput` (string) — the classification summary sent to the reviewer + - `AgentName` (string) — the name of the agent creating the task +- **Output:** + - `Answer` (boolean) — `true` to approve, `false` to reject + +> This approach allows you to use any compatible UiPath App as the escalation interface, swapping it out without redeploying or editing code. + +#### Option B: Deploy the pre-built solution app + Follow these steps to deploy the pre-built application using [UiPath Solutions Management](https://docs.uipath.com/solutions-management/automation-cloud/latest/user-guide/solutions-management-overview): 1. **Upload Solution Package** diff --git a/samples/ticket-classification/bindings.json b/samples/ticket-classification/bindings.json new file mode 100644 index 000000000..d6fbf7f97 --- /dev/null +++ b/samples/ticket-classification/bindings.json @@ -0,0 +1,26 @@ +{ + "version": "2.0", + "resources": [ + { + "resource": "app", + "key": "escalation_agent_app.app_folder_path", + "value": { + "name": { + "defaultValue": "escalation_agent_app", + "isExpression": false, + "displayName": "App Name" + }, + "folderPath": { + "defaultValue": "app_folder_path", + "isExpression": false, + "displayName": "App Folder Path" + } + }, + "metadata": { + "ActivityName": "create_async", + "BindingsVersion": "2.2", + "DisplayLabel": "app_name" + } + } + ] +} diff --git a/samples/ticket-classification/main.py b/samples/ticket-classification/main.py index f11d236bb..aee67b364 100644 --- a/samples/ticket-classification/main.py +++ b/samples/ticket-classification/main.py @@ -125,7 +125,7 @@ async def wait_for_human(state: GraphState) -> Command: "AgentName": "ticket-classification "}, app_version=1, assignee=state.get("assignee", None), - app_folder_path="FOLDER_PATH_PLACEHOLDER", + app_folder_path="app_folder_path", )) return Command( From 74c157b440a59df42533c89005215728b8814dd6 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Mon, 23 Mar 2026 11:52:16 +0200 Subject: [PATCH 24/33] fix: improve 4xx reporting on context and is tools (#714) --- pyproject.toml | 2 +- .../agent/tools/context_tool.py | 27 ++++++- .../agent/tools/integration_tool.py | 22 +++++- tests/agent/tools/conftest.py | 29 +++++++ tests/agent/tools/test_context_tool.py | 66 +++++++++++++++- tests/agent/tools/test_integration_tool.py | 75 ++++++++++++++++++- uv.lock | 2 +- 7 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 tests/agent/tools/conftest.py diff --git a/pyproject.toml b/pyproject.toml index e685a836e..4c2f74c84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.9.0" +version = "0.9.1" 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/context_tool.py b/src/uipath_langchain/agent/tools/context_tool.py index 95fd4961a..70b2b6bec 100644 --- a/src/uipath_langchain/agent/tools/context_tool.py +++ b/src/uipath_langchain/agent/tools/context_tool.py @@ -23,11 +23,16 @@ CitationMode, DeepRagContent, ) +from uipath.platform.errors import EnrichedException from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain._utils import get_execution_folder_path from uipath_langchain._utils.durable_interrupt import durable_interrupt -from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode +from uipath_langchain.agent.exceptions import ( + AgentStartupError, + AgentStartupErrorCode, + raise_for_enriched, +) from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( create_model as create_model_from_schema, ) @@ -50,6 +55,15 @@ logger = logging.getLogger(__name__) +_CONTEXT_GROUNDING_ERRORS: dict[ + tuple[int, str | None], tuple[str, UiPathErrorCategory] +] = { + (400, None): ( + "Context grounding returned an error for index '{index}': {message}", + UiPathErrorCategory.USER, + ), +} + def _build_arg_props_from_settings( resource: AgentContextResourceConfig, @@ -213,7 +227,16 @@ async def context_tool_fn( actual_query = prompt or query assert actual_query is not None - docs = await retriever.ainvoke(actual_query) + try: + docs = await retriever.ainvoke(actual_query) + except EnrichedException as e: + raise_for_enriched( + e, + _CONTEXT_GROUNDING_ERRORS, + title=f"Failed to search context index '{resource.index_name}'", + index=resource.index_name or "", + ) + raise return { "documents": [ {"metadata": doc.metadata, "page_content": doc.page_content} diff --git a/src/uipath_langchain/agent/tools/integration_tool.py b/src/uipath_langchain/agent/tools/integration_tool.py index 62866fd9e..2ead69450 100644 --- a/src/uipath_langchain/agent/tools/integration_tool.py +++ b/src/uipath_langchain/agent/tools/integration_tool.py @@ -17,9 +17,14 @@ from uipath.eval.mocks import mockable from uipath.platform import UiPath from uipath.platform.connections import ActivityMetadata, ActivityParameterLocationInfo +from uipath.platform.errors import EnrichedException from uipath.runtime.errors import UiPathErrorCategory -from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode +from uipath_langchain.agent.exceptions import ( + AgentStartupError, + AgentStartupErrorCode, + raise_for_enriched, +) from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model from uipath_langchain.agent.react.types import AgentGraphState from uipath_langchain.agent.tools.static_args import ( @@ -36,6 +41,13 @@ ) from .utils import sanitize_dict_for_serialization, sanitize_tool_name +_INTEGRATION_ERRORS: dict[tuple[int, str | None], tuple[str, UiPathErrorCategory]] = { + (400, None): ( + "Integration service returned an error for tool '{tool}': {message}", + UiPathErrorCategory.USER, + ), +} + def convert_integration_parameters_to_argument_properties( parameters: list[AgentIntegrationToolParameter], @@ -326,7 +338,13 @@ async def integration_tool_fn(**kwargs: Any): connection_id=connection_id, activity_input=sanitize_dict_for_serialization(kwargs), ) - except Exception: + except EnrichedException as e: + raise_for_enriched( + e, + _INTEGRATION_ERRORS, + title=f"Failed to execute tool '{resource.name}'", + tool=resource.name, + ) raise return result diff --git a/tests/agent/tools/conftest.py b/tests/agent/tools/conftest.py new file mode 100644 index 000000000..863220e12 --- /dev/null +++ b/tests/agent/tools/conftest.py @@ -0,0 +1,29 @@ +"""Shared fixtures for agent tool tests.""" + +import httpx +import pytest +from uipath.platform.errors import EnrichedException + + +@pytest.fixture +def make_enriched_exception(): + """Factory fixture for creating EnrichedException with a given status code and URL.""" + + def _make( + status_code: int, + body: str = "Bad Request", + url: str = "https://cloud.uipath.com/test_/endpoint", + ) -> EnrichedException: + response = httpx.Response( + status_code=status_code, + content=body.encode(), + request=httpx.Request("POST", url), + ) + http_error = httpx.HTTPStatusError( + message=body, + request=response.request, + response=response, + ) + return EnrichedException(http_error) + + return _make diff --git a/tests/agent/tools/test_context_tool.py b/tests/agent/tools/test_context_tool.py index da867a9d6..1c6a7a5b7 100644 --- a/tests/agent/tools/test_context_tool.py +++ b/tests/agent/tools/test_context_tool.py @@ -17,8 +17,15 @@ CitationMode, DeepRagContent, ) - -from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode +from uipath.platform.errors import EnrichedException +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_langchain.agent.exceptions import ( + AgentRuntimeError, + AgentRuntimeErrorCode, + AgentStartupError, + AgentStartupErrorCode, +) from uipath_langchain.agent.tools.context_tool import ( _normalize_folder_prefix, build_glob_pattern, @@ -1031,3 +1038,58 @@ def test_double_star_prefix_preserved(self): def test_double_star_nested_prefix_preserved(self): assert _normalize_folder_prefix("**/docs/reports") == "**/docs/reports" + + +class TestSemanticSearchErrorHandling: + """Test error handling for semantic search HTTP failures.""" + + @pytest.fixture + def semantic_config(self): + return _make_context_resource( + name="test_search", + description="Test search", + retrieval_mode=AgentContextRetrievalMode.SEMANTIC, + query_variant="dynamic", + ) + + @pytest.mark.asyncio + async def test_400_raises_agent_runtime_error_with_user_category( + self, semantic_config, make_enriched_exception + ): + with patch( + "uipath_langchain.agent.tools.context_tool.ContextGroundingRetriever" + ) as mock_retriever_class: + mock_retriever = AsyncMock() + mock_retriever.ainvoke.side_effect = make_enriched_exception( + 400, "One or more validation errors occurred." + ) + mock_retriever_class.return_value = mock_retriever + + tool = handle_semantic_search("test_search", semantic_config) + assert tool.coroutine is not None + + with pytest.raises(AgentRuntimeError) as exc_info: + await tool.coroutine(query="test query") + assert exc_info.value.error_info.category == UiPathErrorCategory.USER + assert exc_info.value.error_info.code == AgentRuntimeError.full_code( + AgentRuntimeErrorCode.HTTP_ERROR + ) + + @pytest.mark.asyncio + async def test_non_400_enriched_exception_propagates( + self, semantic_config, make_enriched_exception + ): + with patch( + "uipath_langchain.agent.tools.context_tool.ContextGroundingRetriever" + ) as mock_retriever_class: + mock_retriever = AsyncMock() + mock_retriever.ainvoke.side_effect = make_enriched_exception( + 500, "Internal Server Error" + ) + mock_retriever_class.return_value = mock_retriever + + tool = handle_semantic_search("test_search", semantic_config) + assert tool.coroutine is not None + + with pytest.raises(EnrichedException): + await tool.coroutine(query="test query") diff --git a/tests/agent/tools/test_integration_tool.py b/tests/agent/tools/test_integration_tool.py index cc1fa0fe5..c95f4aaae 100644 --- a/tests/agent/tools/test_integration_tool.py +++ b/tests/agent/tools/test_integration_tool.py @@ -1,6 +1,6 @@ """Tests for integration_tool.py module.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from uipath.agent.models.agent import ( @@ -11,8 +11,14 @@ AgentToolStaticArgumentProperties, ) from uipath.platform.connections import ActivityParameterLocationInfo, Connection +from uipath.platform.errors import EnrichedException +from uipath.runtime.errors import UiPathErrorCategory -from uipath_langchain.agent.exceptions import AgentStartupError +from uipath_langchain.agent.exceptions import ( + AgentRuntimeError, + AgentRuntimeErrorCode, + AgentStartupError, +) from uipath_langchain.agent.tools.integration_tool import ( _is_param_name_to_jsonpath, _param_name_to_segments, @@ -1055,3 +1061,68 @@ def test_escapes_single_quotes(self): def test_escapes_backslashes(self): assert _is_param_name_to_jsonpath("path\\to") == "$['path\\\\to']" + + +class TestIntegrationToolErrorHandling: + """Test error handling for integration tool HTTP failures.""" + + @pytest.fixture + def common_connection(self): + return Connection( + id="test-connection-id", name="Test Connection", element_instance_id=12345 + ) + + @pytest.fixture + def resource(self, common_connection): + return AgentIntegrationToolResourceConfig( + name="test_tool", + description="Test tool", + properties=AgentIntegrationToolProperties( + method="POST", + tool_path="/api/test", + object_name="test_object", + tool_display_name="Test Tool", + tool_description="Test tool description", + connection=common_connection, + parameters=[], + ), + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + ) + + @pytest.mark.asyncio + @patch("uipath_langchain.agent.tools.integration_tool.UiPath") + async def test_400_raises_agent_runtime_error_with_user_category( + self, mock_uipath_cls, resource, make_enriched_exception + ): + mock_sdk = MagicMock() + mock_sdk.connections.invoke_activity_async = AsyncMock( + side_effect=make_enriched_exception(400, "Bad Request") + ) + mock_uipath_cls.return_value = mock_sdk + + tool = create_integration_tool(resource) + + with pytest.raises(AgentRuntimeError) as exc_info: + await tool.ainvoke({"query": "test"}) + assert exc_info.value.error_info.category == UiPathErrorCategory.USER + assert exc_info.value.error_info.code == AgentRuntimeError.full_code( + AgentRuntimeErrorCode.HTTP_ERROR + ) + + @pytest.mark.asyncio + @patch("uipath_langchain.agent.tools.integration_tool.UiPath") + async def test_non_400_enriched_exception_propagates( + self, mock_uipath_cls, resource, make_enriched_exception + ): + original = make_enriched_exception(500, "Internal Server Error") + mock_sdk = MagicMock() + mock_sdk.connections.invoke_activity_async = AsyncMock(side_effect=original) + mock_uipath_cls.return_value = mock_sdk + + tool = create_integration_tool(resource) + + with pytest.raises(EnrichedException): + await tool.ainvoke({"query": "test"}) diff --git a/uv.lock b/uv.lock index 75d4fc7aa..c7876304c 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.9.0" +version = "0.9.1" source = { editable = "." } dependencies = [ { name = "httpx" }, From 437fac892724f221f4c553b7a63d8e5ab7747583 Mon Sep 17 00:00:00 2001 From: Cosmin MARIA Date: Mon, 23 Mar 2026 15:49:59 +0200 Subject: [PATCH 25/33] Fix: fix bedrock payload handler for parallel and strict tool use, add fallback for thinking on claude (#715) --- .../chat/handlers/__init__.py | 5 +- src/uipath_langchain/chat/handlers/base.py | 4 + src/uipath_langchain/chat/handlers/bedrock.py | 121 ++++++--- .../chat/handlers/handler_factory.py | 20 +- .../chat/handlers/test_tool_binding_kwargs.py | 78 +++++- tests/chat/test_bedrock_payload_handler.py | 233 ++++++++++++++++++ 6 files changed, 400 insertions(+), 61 deletions(-) create mode 100644 tests/chat/test_bedrock_payload_handler.py diff --git a/src/uipath_langchain/chat/handlers/__init__.py b/src/uipath_langchain/chat/handlers/__init__.py index 32da11278..c4bf6cf91 100644 --- a/src/uipath_langchain/chat/handlers/__init__.py +++ b/src/uipath_langchain/chat/handlers/__init__.py @@ -2,14 +2,15 @@ from .anthropic import AnthropicPayloadHandler from .base import DefaultModelPayloadHandler, ModelPayloadHandler -from .bedrock import BedrockPayloadHandler +from .bedrock import BedrockConversePayloadHandler, BedrockInvokePayloadHandler from .gemini import GeminiPayloadHandler from .handler_factory import get_payload_handler from .openai import OpenAIPayloadHandler __all__ = [ "ModelPayloadHandler", - "BedrockPayloadHandler", + "BedrockInvokePayloadHandler", + "BedrockConversePayloadHandler", "OpenAIPayloadHandler", "GeminiPayloadHandler", "AnthropicPayloadHandler", diff --git a/src/uipath_langchain/chat/handlers/base.py b/src/uipath_langchain/chat/handlers/base.py index 083586489..c157d3758 100644 --- a/src/uipath_langchain/chat/handlers/base.py +++ b/src/uipath_langchain/chat/handlers/base.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from typing import Any, Literal, Sequence +from langchain_core.language_models import BaseChatModel from langchain_core.messages import AIMessage from langchain_core.tools import BaseTool @@ -13,6 +14,9 @@ class ModelPayloadHandler(ABC): Each handler provides provider-specific parameter values for LLM operations. """ + def __init__(self, model: BaseChatModel): + self.model = model + @abstractmethod def get_tool_binding_kwargs( self, diff --git a/src/uipath_langchain/chat/handlers/bedrock.py b/src/uipath_langchain/chat/handlers/bedrock.py index c183d59a9..65be087d0 100644 --- a/src/uipath_langchain/chat/handlers/bedrock.py +++ b/src/uipath_langchain/chat/handlers/bedrock.py @@ -1,5 +1,6 @@ -"""AWS Bedrock payload handler.""" +"""AWS Bedrock payload handlers.""" +import logging from collections.abc import Sequence from typing import Any, Literal @@ -10,6 +11,9 @@ from ..exceptions import ChatModelError, ChatModelErrorCode from .base import ModelPayloadHandler +logger = logging.getLogger(__name__) + + # --- Converse API constants --- CONVERSE_FAULTY_REASONS: set[str] = { @@ -68,12 +72,11 @@ } -class BedrockPayloadHandler(ModelPayloadHandler): - """Payload handler for AWS Bedrock Converse and Invoke APIs. +class BedrockInvokePayloadHandler(ModelPayloadHandler): + """Payload handler for ``ChatBedrock`` (AWS Bedrock Invoke API). - Automatically detects the API format from response metadata: - - Converse API uses ``stopReason`` (camelCase) - - Invoke API uses ``stop_reason`` (snake_case) + - Supports ``disable_parallel_tool_use``; ``strict`` is not supported. + - Stop reason field: ``stop_reason`` (snake_case). """ def get_tool_binding_kwargs( @@ -83,16 +86,24 @@ def get_tool_binding_kwargs( parallel_tool_calls: bool = True, strict_mode: bool = False, ) -> dict[str, Any]: - return { - "tool_choice": tool_choice, - } + thinking_enabled = ( + getattr(self.model, "model_kwargs", {}).get("thinking", {}).get("type") + == "enabled" + ) + # Anthropic models via Invoke API don't support forced tool use with extended thinking + if thinking_enabled and tool_choice == "any": + logger.warning( + "Thinking is enabled for the model, but tool_choice is 'any'. " + "Changing tool_choice to 'auto' to keep the same behaviour as ChatAnthropicBedrock." + ) + tool_choice = "auto" + kwargs: dict[str, Any] = {"tool_choice": tool_choice} + if not parallel_tool_calls: + kwargs["disable_parallel_tool_use"] = True + return kwargs def check_stop_reason(self, response: AIMessage) -> None: - """Check Bedrock stop reason and raise exception for faulty terminations. - - Handles both API formats: - - Converse: checks ``stopReason`` (camelCase) in response_metadata - - Invoke: checks ``stop_reason`` (snake_case) in response_metadata + """Check ``stop_reason`` (snake_case) and raise for faulty terminations. Args: response: The AIMessage response from the model. @@ -100,29 +111,7 @@ def check_stop_reason(self, response: AIMessage) -> None: Raises: ChatModelError: If the stop reason indicates a faulty termination. """ - metadata = response.response_metadata - - # --- Converse API: stopReason (camelCase) --- - stop_reason = metadata.get("stopReason") - if stop_reason: - if stop_reason in CONVERSE_FAULTY_REASONS: - title, detail = CONVERSE_ERROR_MESSAGES.get( - stop_reason, - ( - f"Model stopped with reason: {stop_reason}", - f"The model terminated with stop reason '{stop_reason}'.", - ), - ) - raise ChatModelError( - code=ChatModelErrorCode.UNSUCCESSFUL_STOP_REASON, - title=title, - detail=detail, - category=UiPathErrorCategory.USER, - ) - return - - # --- Invoke API: stop_reason (snake_case) --- - stop_reason = metadata.get("stop_reason") + stop_reason = response.response_metadata.get("stop_reason") if stop_reason and stop_reason in INVOKE_FAULTY_REASONS: title, detail = INVOKE_ERROR_MESSAGES.get( stop_reason, @@ -137,3 +126,61 @@ def check_stop_reason(self, response: AIMessage) -> None: detail=detail, category=UiPathErrorCategory.USER, ) + + +class BedrockConversePayloadHandler(ModelPayloadHandler): + """Payload handler for ``ChatBedrockConverse`` (AWS Bedrock Converse API). + + - Supports ``strict``; ``parallel_tool_calls`` is not supported. + - Stop reason field: ``stopReason`` (camelCase). + """ + + def get_tool_binding_kwargs( + self, + tools: Sequence[BaseTool], + tool_choice: Literal["auto", "any"], + parallel_tool_calls: bool = True, + strict_mode: bool = False, + ) -> dict[str, Any]: + thinking_enabled = ( + getattr(self.model, "additional_model_request_fields", {}) + .get("thinking", {}) + .get("type") + == "enabled" + ) + # Anthropic models via Converse API don't support forced tool use with extended thinking + if thinking_enabled and tool_choice == "any": + logger.warning( + "Thinking is enabled for the model, but tool_choice is 'any'. " + "Changing tool_choice to 'auto' to keep the same behaviour as ChatAnthropicBedrock." + ) + tool_choice = "auto" + kwargs: dict[str, Any] = {"tool_choice": tool_choice} + if strict_mode: + kwargs["strict"] = True + return kwargs + + def check_stop_reason(self, response: AIMessage) -> None: + """Check ``stopReason`` (camelCase) and raise for faulty terminations. + + Args: + response: The AIMessage response from the model. + + Raises: + ChatModelError: If the stop reason indicates a faulty termination. + """ + stop_reason = response.response_metadata.get("stopReason") + if stop_reason and stop_reason in CONVERSE_FAULTY_REASONS: + title, detail = CONVERSE_ERROR_MESSAGES.get( + stop_reason, + ( + f"Model stopped with reason: {stop_reason}", + f"The model terminated with stop reason '{stop_reason}'.", + ), + ) + raise ChatModelError( + code=ChatModelErrorCode.UNSUCCESSFUL_STOP_REASON, + title=title, + detail=detail, + category=UiPathErrorCategory.USER, + ) diff --git a/src/uipath_langchain/chat/handlers/handler_factory.py b/src/uipath_langchain/chat/handlers/handler_factory.py index 7f0dc3390..762068f8f 100644 --- a/src/uipath_langchain/chat/handlers/handler_factory.py +++ b/src/uipath_langchain/chat/handlers/handler_factory.py @@ -4,7 +4,7 @@ from .anthropic import AnthropicPayloadHandler from .base import DefaultModelPayloadHandler, ModelPayloadHandler -from .bedrock import BedrockPayloadHandler +from .bedrock import BedrockConversePayloadHandler, BedrockInvokePayloadHandler from .gemini import GeminiPayloadHandler from .openai import OpenAIPayloadHandler @@ -23,14 +23,16 @@ def get_payload_handler(model: BaseChatModel) -> ModelPayloadHandler: A ModelPayloadHandler instance for the model. """ - model_mro = [m.__name__ for m in type(model).mro()] + model_mro = set([m.__name__ for m in type(model).mro()]) - if "AzureChatOpenAI" in model_mro or "ChatOpenAI" in model_mro: - return OpenAIPayloadHandler() + if "BaseChatOpenAI" in model_mro: + return OpenAIPayloadHandler(model) if "ChatAnthropic" in model_mro: - return AnthropicPayloadHandler() + return AnthropicPayloadHandler(model) if "ChatGoogleGenerativeAI" in model_mro: - return GeminiPayloadHandler() - if "ChatBedrock" in model_mro or "ChatBedrockConverse" in model_mro: - return BedrockPayloadHandler() - return DefaultModelPayloadHandler() + return GeminiPayloadHandler(model) + if "ChatBedrockConverse" in model_mro: + return BedrockConversePayloadHandler(model) + if "ChatBedrock" in model_mro: + return BedrockInvokePayloadHandler(model) + return DefaultModelPayloadHandler(model) diff --git a/tests/chat/handlers/test_tool_binding_kwargs.py b/tests/chat/handlers/test_tool_binding_kwargs.py index 492e3bd33..5fee8f5f9 100644 --- a/tests/chat/handlers/test_tool_binding_kwargs.py +++ b/tests/chat/handlers/test_tool_binding_kwargs.py @@ -2,11 +2,15 @@ from unittest.mock import Mock +from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool from uipath_langchain.chat.handlers.anthropic import AnthropicPayloadHandler from uipath_langchain.chat.handlers.base import DefaultModelPayloadHandler -from uipath_langchain.chat.handlers.bedrock import BedrockPayloadHandler +from uipath_langchain.chat.handlers.bedrock import ( + BedrockConversePayloadHandler, + BedrockInvokePayloadHandler, +) from uipath_langchain.chat.handlers.gemini import GeminiPayloadHandler from uipath_langchain.chat.handlers.openai import OpenAIPayloadHandler @@ -30,7 +34,7 @@ class TestDefaultGetToolBindingKwargs: """DefaultModelPayloadHandler returns only tool_choice.""" def setup_method(self): - self.handler = DefaultModelPayloadHandler() + self.handler = DefaultModelPayloadHandler(Mock(spec=BaseChatModel)) self.tools = _make_tools("tool_a") def test_tool_choice_auto(self): @@ -65,7 +69,7 @@ class TestOpenAIGetToolBindingKwargs: """OpenAIPayloadHandler returns tool_choice, parallel_tool_calls, strict.""" def setup_method(self): - self.handler = OpenAIPayloadHandler() + self.handler = OpenAIPayloadHandler(Mock(spec=BaseChatModel)) self.tools = _make_tools("tool_a") def test_tool_choice_auto(self): @@ -123,7 +127,7 @@ class TestAnthropicGetToolBindingKwargs: """AnthropicPayloadHandler returns tool_choice, parallel_tool_calls, strict.""" def setup_method(self): - self.handler = AnthropicPayloadHandler() + self.handler = AnthropicPayloadHandler(Mock(spec=BaseChatModel)) self.tools = _make_tools("tool_a") def test_tool_choice_auto(self): @@ -181,7 +185,7 @@ class TestGeminiGetToolBindingKwargs: """GeminiPayloadHandler returns a nested tool_config dict.""" def setup_method(self): - self.handler = GeminiPayloadHandler() + self.handler = GeminiPayloadHandler(Mock(spec=BaseChatModel)) self.tools = _make_tools("get_weather", "search") def test_mode_auto(self): @@ -224,15 +228,15 @@ def test_only_tool_config_key(self): # --------------------------------------------------------------------------- -# Bedrock handler +# Bedrock Invoke handler (ChatBedrock) # --------------------------------------------------------------------------- -class TestBedrockGetToolBindingKwargs: - """BedrockPayloadHandler returns only tool_choice.""" +class TestBedrockInvokeGetToolBindingKwargs: + """BedrockInvokePayloadHandler: tool_choice + optional disable_parallel_tool_use.""" def setup_method(self): - self.handler = BedrockPayloadHandler() + self.handler = BedrockInvokePayloadHandler(Mock(spec=BaseChatModel)) self.tools = _make_tools("tool_a") def test_tool_choice_auto(self): @@ -255,21 +259,21 @@ def test_result_contains_tool_choice_key(self): assert "tool_choice" in result def test_parallel_tool_calls_not_included(self): - """Bedrock does not support parallel_tool_calls in binding kwargs.""" + """Invoke API uses disable_parallel_tool_use, not parallel_tool_calls.""" result = self.handler.get_tool_binding_kwargs( tools=self.tools, tool_choice="auto", parallel_tool_calls=True ) assert "parallel_tool_calls" not in result def test_strict_mode_not_included(self): - """Bedrock does not support strict mode in binding kwargs.""" + """Invoke API does not support strict mode.""" result = self.handler.get_tool_binding_kwargs( tools=self.tools, tool_choice="auto", strict_mode=True ) assert "strict" not in result - def test_only_tool_choice_returned(self): - """Ensure exactly one key is returned regardless of input params.""" + def test_only_tool_choice_returned_when_parallel_true(self): + """With parallel_tool_calls=True no extra keys are added.""" result = self.handler.get_tool_binding_kwargs( tools=self.tools, tool_choice="any", @@ -277,3 +281,51 @@ def test_only_tool_choice_returned(self): strict_mode=True, ) assert list(result.keys()) == ["tool_choice"] + + +# --------------------------------------------------------------------------- +# Bedrock Converse handler (ChatBedrockConverse) +# --------------------------------------------------------------------------- + + +class TestBedrockConverseGetToolBindingKwargs: + """BedrockConversePayloadHandler: tool_choice + optional strict.""" + + def setup_method(self): + self.handler = BedrockConversePayloadHandler(Mock(spec=BaseChatModel)) + self.tools = _make_tools("tool_a") + + def test_tool_choice_auto(self): + result = self.handler.get_tool_binding_kwargs( + tools=self.tools, tool_choice="auto" + ) + assert result == {"tool_choice": "auto"} + + def test_tool_choice_any(self): + result = self.handler.get_tool_binding_kwargs( + tools=self.tools, tool_choice="any" + ) + assert result == {"tool_choice": "any"} + + def test_strict_mode_included(self): + result = self.handler.get_tool_binding_kwargs( + tools=self.tools, tool_choice="auto", strict_mode=True + ) + assert result == {"tool_choice": "auto", "strict": True} + + def test_parallel_tool_calls_not_included(self): + """Converse API does not support parallel_tool_calls.""" + result = self.handler.get_tool_binding_kwargs( + tools=self.tools, tool_choice="auto", parallel_tool_calls=False + ) + assert "parallel_tool_calls" not in result + assert "disable_parallel_tool_use" not in result + + def test_only_tool_choice_returned_when_strict_false(self): + result = self.handler.get_tool_binding_kwargs( + tools=self.tools, + tool_choice="any", + parallel_tool_calls=False, + strict_mode=False, + ) + assert list(result.keys()) == ["tool_choice"] diff --git a/tests/chat/test_bedrock_payload_handler.py b/tests/chat/test_bedrock_payload_handler.py new file mode 100644 index 000000000..d10429ad9 --- /dev/null +++ b/tests/chat/test_bedrock_payload_handler.py @@ -0,0 +1,233 @@ +"""Tests for BedrockInvokePayloadHandler and BedrockConversePayloadHandler.""" + +import logging + +import pytest +from langchain_core.messages import AIMessage + +from uipath_langchain.chat.exceptions import ChatModelError +from uipath_langchain.chat.handlers.bedrock import ( + BedrockConversePayloadHandler, + BedrockInvokePayloadHandler, +) + +# --------------------------------------------------------------------------- +# Fake model factories +# --------------------------------------------------------------------------- + + +def make_invoke_model(**model_kwargs_override: object) -> object: + """Return a ChatBedrock-like model with optional model_kwargs.""" + model = type("FakeChatBedrock", (), {"model_kwargs": {}})() + model.model_kwargs = model_kwargs_override + return model + + +def make_converse_model(**fields_override: object) -> object: + """Return a ChatBedrockConverse-like model with optional additional_model_request_fields.""" + model = type( + "FakeChatBedrockConverse", (), {"additional_model_request_fields": {}} + )() + model.additional_model_request_fields = fields_override + return model + + +def make_thinking_invoke_model() -> object: + return make_invoke_model(thinking={"type": "enabled"}) + + +def make_thinking_converse_model() -> object: + return make_converse_model(thinking={"type": "enabled"}) + + +# --------------------------------------------------------------------------- +# BedrockInvokePayloadHandler — get_tool_binding_kwargs +# --------------------------------------------------------------------------- + + +class TestBedrockInvokeGetToolBindingKwargs: + def setup_method(self) -> None: + self.handler = BedrockInvokePayloadHandler(make_invoke_model()) # type: ignore[arg-type] + + def test_auto_no_flags(self) -> None: + result = self.handler.get_tool_binding_kwargs([], "auto") + assert result == {"tool_choice": "auto"} + + def test_any_parallel_true(self) -> None: + result = self.handler.get_tool_binding_kwargs( + [], "any", parallel_tool_calls=True + ) + assert result == {"tool_choice": "any"} + + def test_any_parallel_false_sets_disable_flag(self) -> None: + result = self.handler.get_tool_binding_kwargs( + [], "any", parallel_tool_calls=False + ) + assert result == {"tool_choice": "any", "disable_parallel_tool_use": True} + + def test_auto_parallel_false_sets_disable_flag(self) -> None: + result = self.handler.get_tool_binding_kwargs( + [], "auto", parallel_tool_calls=False + ) + assert result == {"tool_choice": "auto", "disable_parallel_tool_use": True} + + def test_strict_mode_ignored(self) -> None: + result = self.handler.get_tool_binding_kwargs([], "auto", strict_mode=True) + assert "strict" not in result + + def test_thinking_downgrades_any_to_auto(self) -> None: + handler = BedrockInvokePayloadHandler(make_thinking_invoke_model()) # type: ignore[arg-type] + result = handler.get_tool_binding_kwargs([], "any") + assert result["tool_choice"] == "auto" + + def test_thinking_does_not_downgrade_auto(self) -> None: + handler = BedrockInvokePayloadHandler(make_thinking_invoke_model()) # type: ignore[arg-type] + result = handler.get_tool_binding_kwargs([], "auto") + assert result["tool_choice"] == "auto" + + def test_thinking_emits_warning(self, caplog: pytest.LogCaptureFixture) -> None: + handler = BedrockInvokePayloadHandler(make_thinking_invoke_model()) # type: ignore[arg-type] + with caplog.at_level(logging.WARNING): + handler.get_tool_binding_kwargs([], "any") + assert any("tool_choice" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# BedrockConversePayloadHandler — get_tool_binding_kwargs +# --------------------------------------------------------------------------- + + +class TestBedrockConverseGetToolBindingKwargs: + def setup_method(self) -> None: + self.handler = BedrockConversePayloadHandler(make_converse_model()) # type: ignore[arg-type] + + def test_auto_no_flags(self) -> None: + result = self.handler.get_tool_binding_kwargs([], "auto") + assert result == {"tool_choice": "auto"} + + def test_any_no_flags(self) -> None: + result = self.handler.get_tool_binding_kwargs([], "any") + assert result == {"tool_choice": "any"} + + def test_strict_mode_included(self) -> None: + result = self.handler.get_tool_binding_kwargs([], "auto", strict_mode=True) + assert result == {"tool_choice": "auto", "strict": True} + + def test_any_strict_mode_included(self) -> None: + result = self.handler.get_tool_binding_kwargs([], "any", strict_mode=True) + assert result == {"tool_choice": "any", "strict": True} + + def test_parallel_tool_calls_ignored(self) -> None: + result = self.handler.get_tool_binding_kwargs( + [], "auto", parallel_tool_calls=False + ) + assert "disable_parallel_tool_use" not in result + + def test_thinking_downgrades_any_to_auto(self) -> None: + handler = BedrockConversePayloadHandler(make_thinking_converse_model()) # type: ignore[arg-type] + result = handler.get_tool_binding_kwargs([], "any") + assert result["tool_choice"] == "auto" + + def test_thinking_does_not_downgrade_auto(self) -> None: + handler = BedrockConversePayloadHandler(make_thinking_converse_model()) # type: ignore[arg-type] + result = handler.get_tool_binding_kwargs([], "auto") + assert result["tool_choice"] == "auto" + + def test_thinking_emits_warning(self, caplog: pytest.LogCaptureFixture) -> None: + handler = BedrockConversePayloadHandler(make_thinking_converse_model()) # type: ignore[arg-type] + with caplog.at_level(logging.WARNING): + handler.get_tool_binding_kwargs([], "any") + assert any("tool_choice" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# BedrockInvokePayloadHandler — check_stop_reason +# --------------------------------------------------------------------------- + + +class TestBedrockInvokeCheckStopReason: + def setup_method(self) -> None: + self.handler = BedrockInvokePayloadHandler(make_invoke_model()) # type: ignore[arg-type] + + def test_stop_sequence_no_raise(self) -> None: + msg = AIMessage( + content="ok", response_metadata={"stop_reason": "stop_sequence"} + ) + self.handler.check_stop_reason(msg) + + def test_max_tokens_raises(self) -> None: + msg = AIMessage(content="", response_metadata={"stop_reason": "max_tokens"}) + with pytest.raises(ChatModelError): + self.handler.check_stop_reason(msg) + + def test_refusal_raises(self) -> None: + msg = AIMessage(content="", response_metadata={"stop_reason": "refusal"}) + with pytest.raises(ChatModelError): + self.handler.check_stop_reason(msg) + + def test_context_window_exceeded_raises(self) -> None: + msg = AIMessage( + content="", + response_metadata={"stop_reason": "model_context_window_exceeded"}, + ) + with pytest.raises(ChatModelError): + self.handler.check_stop_reason(msg) + + def test_no_stop_reason_no_raise(self) -> None: + msg = AIMessage(content="ok", response_metadata={}) + self.handler.check_stop_reason(msg) + + def test_converse_camel_case_key_ignored(self) -> None: + """Invoke handler must not react to stopReason (camelCase).""" + msg = AIMessage(content="", response_metadata={"stopReason": "max_tokens"}) + self.handler.check_stop_reason(msg) # should not raise + + +# --------------------------------------------------------------------------- +# BedrockConversePayloadHandler — check_stop_reason +# --------------------------------------------------------------------------- + + +class TestBedrockConverseCheckStopReason: + def setup_method(self) -> None: + self.handler = BedrockConversePayloadHandler(make_converse_model()) # type: ignore[arg-type] + + def test_end_turn_no_raise(self) -> None: + msg = AIMessage(content="ok", response_metadata={"stopReason": "end_turn"}) + self.handler.check_stop_reason(msg) + + def test_max_tokens_raises(self) -> None: + msg = AIMessage(content="", response_metadata={"stopReason": "max_tokens"}) + with pytest.raises(ChatModelError): + self.handler.check_stop_reason(msg) + + def test_guardrail_intervened_raises(self) -> None: + msg = AIMessage( + content="", response_metadata={"stopReason": "guardrail_intervened"} + ) + with pytest.raises(ChatModelError): + self.handler.check_stop_reason(msg) + + def test_content_filtered_raises(self) -> None: + msg = AIMessage( + content="", response_metadata={"stopReason": "content_filtered"} + ) + with pytest.raises(ChatModelError): + self.handler.check_stop_reason(msg) + + def test_context_window_exceeded_raises(self) -> None: + msg = AIMessage( + content="", + response_metadata={"stopReason": "model_context_window_exceeded"}, + ) + with pytest.raises(ChatModelError): + self.handler.check_stop_reason(msg) + + def test_no_stop_reason_no_raise(self) -> None: + msg = AIMessage(content="ok", response_metadata={}) + self.handler.check_stop_reason(msg) + + def test_invoke_snake_case_key_ignored(self) -> None: + """Converse handler must not react to stop_reason (snake_case).""" + msg = AIMessage(content="", response_metadata={"stop_reason": "max_tokens"}) + self.handler.check_stop_reason(msg) # should not raise From f02cacc72e85d94a24da46a9c51cdff9ed09f1d3 Mon Sep 17 00:00:00 2001 From: Gabriel Martin <91462031+gcuip@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:51:11 +0100 Subject: [PATCH 26/33] chore: bump uipath-platform - 0.1.5 (#720) --- pyproject.toml | 4 ++-- uv.lock | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4c2f74c84..1b716997c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath-langchain" -version = "0.9.1" +version = "0.9.2" 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.10.22, <2.11.0", "uipath-core>=0.5.2, <0.6.0", - "uipath-platform>=0.1.1, <0.2.0", + "uipath-platform>=0.1.5, <0.2.0", "uipath-runtime>=0.9.1, <0.10.0", "langgraph>=1.0.0, <2.0.0", "langchain-core>=1.2.11, <2.0.0", diff --git a/uv.lock b/uv.lock index c7876304c..715b0c9ac 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.9.1" +version = "0.9.2" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -3402,7 +3402,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "uipath", specifier = ">=2.10.22,<2.11.0" }, { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, - { name = "uipath-platform", specifier = ">=0.1.1,<0.2.0" }, + { name = "uipath-platform", specifier = ">=0.1.5,<0.2.0" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, ] provides-extras = ["vertex", "bedrock"] @@ -3425,7 +3425,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.1" +version = "0.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3435,9 +3435,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/6f/09af81298e5400e414eedc7057baf43742b8acf4c89ef724a9e87f2cce66/uipath_platform-0.1.1.tar.gz", hash = "sha256:f1faabb10ea0adee7e06d8d864c2d1702301534b505f8602723821662cb1b9c9", size = 285000, upload-time = "2026-03-20T09:58:18.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/b3/4cf64b6be903316b513ba094a5357c0d5e1be58f0d6ea837c06ae5e32092/uipath_platform-0.1.5.tar.gz", hash = "sha256:9b04eebe261591fb6c138067d427fa9b1554a3e38fef1ab508c4711f41861999", size = 285153, upload-time = "2026-03-23T22:40:05.833Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/a4/9c9130d2119e76b730e97287c5c10bee58889ea9b7644ade9efe5ccb91ce/uipath_platform-0.1.1-py3-none-any.whl", hash = "sha256:1987a617b1b10ee88c26393cbba371196e1f55b481d59f88b0b386f82987bf51", size = 175883, upload-time = "2026-03-20T09:58:16.956Z" }, + { url = "https://files.pythonhosted.org/packages/52/c3/1beebcf261777945daf2633a8c9a03a86545415032dac208d369fef54412/uipath_platform-0.1.5-py3-none-any.whl", hash = "sha256:0bbe17f8afa06d82a5d196a7b75a308fc2765cee1f5830ba64ad660977ebf162", size = 176100, upload-time = "2026-03-23T22:40:04.147Z" }, ] [[package]] From 98460a9551a6a24a0d81181e4950d650266454ca Mon Sep 17 00:00:00 2001 From: milind-jain-uipath Date: Tue, 24 Mar 2026 12:03:39 +0530 Subject: [PATCH 27/33] use name & field names instead of display name --- .../tools/datafabric_tool/datafabric_tool.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py b/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py index 03df15de8..053345083 100644 --- a/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py +++ b/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py @@ -131,8 +131,9 @@ def format_schemas_for_context(entities: list[Entity]) -> str: lines.append("") for entity in entities: + sql_table = entity.name display_name = entity.display_name or entity.name - lines.append(f"### Entity: {display_name}") + lines.append(f"### Entity: {display_name} (SQL table: `{sql_table}`)") if entity.description: lines.append(f"_{entity.description}_") lines.append("") @@ -146,7 +147,7 @@ def format_schemas_for_context(entities: list[Entity]) -> str: for field in entity.fields or []: if field.is_hidden_field or field.is_system_field: continue - field_name = field.display_name or field.name + field_name = field.name field_type = format_field_type(field) field_names.append(field_name) lines.append(f"| {field_name} | {field_type} |") @@ -178,27 +179,27 @@ def format_schemas_for_context(entities: list[Entity]) -> str: filter_field = text_field or (field_names[0] if field_names else "Name") fields_sample = ", ".join(field_names[:5]) if field_names else "*" - lines.append(f"**Query Patterns for {display_name}:**") + lines.append(f"**Query Patterns for {sql_table}:**") lines.append("") lines.append("| User Intent | SQL Pattern |") lines.append("|-------------|-------------|") lines.append( - f"| 'Show all {display_name.lower()}' | `SELECT {fields_sample} FROM {display_name} LIMIT 100` |" + f"| 'Show all' | `SELECT {fields_sample} FROM {sql_table} LIMIT 100` |" ) lines.append( - f"| 'Find by X' | `SELECT {fields_sample} FROM {display_name} WHERE {filter_field} = 'value' LIMIT 100` |" + f"| 'Find by X' | `SELECT {fields_sample} FROM {sql_table} WHERE {filter_field} = 'value' LIMIT 100` |" ) lines.append( - f"| 'Top N by Y' | `SELECT {fields_sample} FROM {display_name} ORDER BY {agg_field} DESC LIMIT N` |" + f"| 'Top N by Y' | `SELECT {fields_sample} FROM {sql_table} ORDER BY {agg_field} DESC LIMIT N` |" ) lines.append( - f"| 'Count by X' | `SELECT {group_field}, COUNT(*) as count FROM {display_name} GROUP BY {group_field}` |" + f"| 'Count by X' | `SELECT {group_field}, COUNT(*) as count FROM {sql_table} GROUP BY {group_field}` |" ) lines.append( - f"| 'Top N segments' | `SELECT {group_field}, COUNT(*) as count FROM {display_name} GROUP BY {group_field} ORDER BY count DESC LIMIT N` |" + f"| 'Top N segments' | `SELECT {group_field}, COUNT(*) as count FROM {sql_table} GROUP BY {group_field} ORDER BY count DESC LIMIT N` |" ) lines.append( - f"| 'Sum/Avg of Y' | `SELECT SUM({agg_field}) as total FROM {display_name}` |" + f"| 'Sum/Avg of Y' | `SELECT SUM({agg_field}) as total FROM {sql_table}` |" ) lines.append("") From e7a7c337dbcf02364e594c0a059f40dda977c8cc Mon Sep 17 00:00:00 2001 From: Cristian Cotovanu <87022468+cotovanu-cristian@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:05:23 +0200 Subject: [PATCH 28/33] fix: surface actionable error for dangling $ref in tool schemas (#716) Co-authored-by: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 +- .../react/jsonschema_pydantic_converter.py | 24 +- .../agent/tools/integration_tool.py | 4 +- .../test_jsonschema_pydantic_converter.py | 298 ++++++++++++++++++ tests/agent/tools/test_integration_tool.py | 63 ++++ uv.lock | 2 +- 6 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 tests/agent/react/test_jsonschema_pydantic_converter.py diff --git a/pyproject.toml b/pyproject.toml index 1b716997c..65353c588 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.9.2" +version = "0.9.3" 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/react/jsonschema_pydantic_converter.py b/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py index e6cecbae7..e0b9fdb6a 100644 --- a/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py +++ b/src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py @@ -4,7 +4,9 @@ from typing import Any, Type from jsonschema_pydantic_converter import transform_with_modules -from pydantic import BaseModel +from pydantic import BaseModel, PydanticUndefinedAnnotation + +from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode # Shared pseudo-module for all dynamically created types # This allows get_type_hints() to resolve forward references @@ -25,7 +27,25 @@ def _get_or_create_dynamic_module() -> ModuleType: def create_model( schema: dict[str, Any], ) -> Type[BaseModel]: - model, namespace = transform_with_modules(schema) + """Convert a JSON schema dict to a Pydantic model. + + Raises: + AgentStartupError: If the schema contains a type that cannot be resolved. + """ + try: + model, namespace = transform_with_modules(schema) + except PydanticUndefinedAnnotation as e: + # Strip the __ prefix the converter adds to forward references + # so the user sees the original type name from their JSON schema. + type_name = e.name.lstrip("_") if e.name else None + raise AgentStartupError( + code=AgentStartupErrorCode.INVALID_TOOL_CONFIG, + title="Invalid schema", + detail=( + f"Type '{type_name}' could not be resolved. " + f"Check that all $ref targets have matching entries in $defs." + ), + ) from e pseudo_module = _get_or_create_dynamic_module() diff --git a/src/uipath_langchain/agent/tools/integration_tool.py b/src/uipath_langchain/agent/tools/integration_tool.py index 2ead69450..09beae8e3 100644 --- a/src/uipath_langchain/agent/tools/integration_tool.py +++ b/src/uipath_langchain/agent/tools/integration_tool.py @@ -209,7 +209,9 @@ def fix_types(props: dict[str, Any]) -> None: k = k.replace("[*]", "") definitions[k] = value fix_types(value) - if "definitions" in fields: + if "$defs" in fields: + fields["$defs"] = definitions + elif "definitions" in fields: fields["definitions"] = definitions fix_types(fields) diff --git a/tests/agent/react/test_jsonschema_pydantic_converter.py b/tests/agent/react/test_jsonschema_pydantic_converter.py new file mode 100644 index 000000000..2063b7156 --- /dev/null +++ b/tests/agent/react/test_jsonschema_pydantic_converter.py @@ -0,0 +1,298 @@ +"""Tests for jsonschema_pydantic_converter wrapper — create_model().""" + +from typing import Any + +import pytest + +from uipath_langchain.agent.exceptions import AgentStartupError +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model + +# --- Fixtures: reusable schema fragments --- + + +@pytest.fixture() +def contact_def() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "fullname": {"type": "string"}, + "email": {"type": "string"}, + }, + } + + +@pytest.fixture() +def schema_with_defs(contact_def: dict[str, Any]) -> dict[str, Any]: + """Schema with properly matched $ref and $defs.""" + return { + "type": "object", + "properties": { + "owner": {"$ref": "#/$defs/Contact"}, + }, + "$defs": { + "Contact": contact_def, + }, + } + + +# --- 1. Dangling $ref (unresolvable type references) --- + + +class TestDanglingRef: + """Schemas where $ref points to a type not in $defs.""" + + def test_ref_to_missing_defs_raises(self) -> None: + schema = { + "type": "object", + "properties": { + "owner": {"$ref": "#/$defs/Contact"}, + }, + } + with pytest.raises(AgentStartupError, match=r"Contact.*could not be resolved"): + create_model(schema) + + def test_malformed_ref_path_raises(self) -> None: + schema = { + "type": "object", + "properties": { + "owner": {"$ref": "Contact"}, + }, + } + with pytest.raises(AgentStartupError, match=r"Contact.*could not be resolved"): + create_model(schema) + + def test_nested_defs_with_root_relative_ref_raises(self) -> None: + """$defs inside 'items' with root-relative $ref paths. + + When $defs are placed inside a nested object (e.g. array items) + but $ref uses root-relative paths (#/$defs/...), the converter + cannot reach the definitions. + """ + schema = { + "type": "object", + "properties": { + "records": { + "type": "array", + "items": { + "type": "object", + "properties": { + "author": {"$ref": "#/$defs/Person"}, + "title": {"type": "string"}, + }, + "$defs": { + "Person": { + "type": "object", + "properties": { + "Name": {"type": "string"}, + "Email": {"type": "string"}, + }, + }, + "Timestamp": { + "type": "string", + "format": "date-time", + }, + }, + }, + } + }, + } + with pytest.raises(AgentStartupError, match=r"Person.*could not be resolved"): + create_model(schema) + + def test_ref_to_partial_defs_raises_for_missing_type(self) -> None: + """$defs has some types but not the one referenced.""" + schema = { + "type": "object", + "properties": { + "report": {"$ref": "#/$defs/Report"}, + }, + "$defs": { + "Report": { + "type": "object", + "properties": { + "reviewer": {"$ref": "#/$defs/Contact"}, + }, + }, + # Contact is missing + }, + } + with pytest.raises(AgentStartupError, match=r"Contact.*could not be resolved"): + create_model(schema) + + +# --- 2. Valid $ref/$defs (happy paths) --- + + +class TestValidDefs: + """Schemas where $ref and $defs are properly matched.""" + + def test_ref_with_matching_defs(self, schema_with_defs: dict[str, Any]) -> None: + model = create_model(schema_with_defs) + assert model.__pydantic_complete__ + assert "owner" in model.model_fields + + def test_cross_referencing_defs(self, contact_def: dict[str, Any]) -> None: + schema = { + "type": "object", + "properties": { + "report": {"$ref": "#/$defs/Report"}, + }, + "$defs": { + "Report": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "reviewer": {"$ref": "#/$defs/Contact"}, + }, + }, + "Contact": contact_def, + }, + } + model = create_model(schema) + assert model.__pydantic_complete__ + assert "report" in model.model_fields + + def test_definitions_keyword(self, contact_def: dict[str, Any]) -> None: + """Old-style 'definitions' keyword (not $defs).""" + schema = { + "type": "object", + "properties": { + "owner": {"$ref": "#/definitions/Contact"}, + }, + "definitions": { + "Contact": contact_def, + }, + } + model = create_model(schema) + assert model.__pydantic_complete__ + assert "owner" in model.model_fields + + +# --- 3. Static args round-trip (model → JSON schema → model) --- + + +class TestStaticArgsRoundTrip: + """Simulates static_args.py: model_json_schema() → modify → create_model(). + + The round-trip regenerates $defs with Pydantic-chosen keys. + All $ref entries must still resolve after the round-trip. + """ + + def test_round_trip_preserves_defs(self, schema_with_defs: dict[str, Any]) -> None: + model = create_model(schema_with_defs) + round_tripped = model.model_json_schema() + model2 = create_model(round_tripped) + assert model2.__pydantic_complete__ + assert "owner" in model2.model_fields + + def test_round_trip_with_cross_refs(self, contact_def: dict[str, Any]) -> None: + schema = { + "type": "object", + "properties": { + "report": {"$ref": "#/$defs/Report"}, + "reviewer": {"$ref": "#/$defs/Contact"}, + }, + "$defs": { + "Report": { + "type": "object", + "properties": { + "owner": {"$ref": "#/$defs/Contact"}, + "items": { + "type": "array", + "items": {"$ref": "#/$defs/Contact"}, + }, + }, + }, + "Contact": contact_def, + }, + } + model = create_model(schema) + round_tripped = model.model_json_schema() + assert "$defs" in round_tripped or "definitions" in round_tripped + model2 = create_model(round_tripped) + assert model2.__pydantic_complete__ + assert "report" in model2.model_fields + assert "reviewer" in model2.model_fields + + def test_round_trip_after_property_removal( + self, schema_with_defs: dict[str, Any] + ) -> None: + """Simulates static_args removing a property from the schema.""" + model = create_model(schema_with_defs) + round_tripped = model.model_json_schema() + + # Add a simple field, then remove it (like static_args does) + round_tripped["properties"]["extra"] = {"type": "string"} + round_tripped["properties"].pop("extra") + + model2 = create_model(round_tripped) + assert model2.__pydantic_complete__ + assert "owner" in model2.model_fields + + +# --- 4. Pseudo-module isolation across multiple create_model calls --- + + +class TestPseudoModuleIsolation: + """Multiple create_model calls share one pseudo-module. + + Each call must produce a working model regardless of prior calls. + """ + + def test_sequential_models_with_same_def_name( + self, contact_def: dict[str, Any] + ) -> None: + schema_a = { + "type": "object", + "properties": {"owner": {"$ref": "#/$defs/Contact"}}, + "$defs": {"Contact": contact_def}, + } + schema_b = { + "type": "object", + "properties": {"author": {"$ref": "#/$defs/Contact"}}, + "$defs": { + "Contact": { + "type": "object", + "properties": {"id": {"type": "integer"}}, + } + }, + } + + model_a = create_model(schema_a) + model_b = create_model(schema_b) + + assert model_a.__pydantic_complete__ + assert model_b.__pydantic_complete__ + assert "owner" in model_a.model_fields + assert "author" in model_b.model_fields + + def test_model_from_simple_schema_after_complex( + self, contact_def: dict[str, Any] + ) -> None: + complex_schema = { + "type": "object", + "properties": { + "report": {"$ref": "#/$defs/Report"}, + }, + "$defs": { + "Report": { + "type": "object", + "properties": { + "owner": {"$ref": "#/$defs/Contact"}, + }, + }, + "Contact": contact_def, + }, + } + simple_schema = { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + + complex_model = create_model(complex_schema) + model = create_model(simple_schema) + + assert complex_model.__pydantic_complete__ + assert "report" in complex_model.model_fields + assert model.__pydantic_complete__ + assert "name" in model.model_fields diff --git a/tests/agent/tools/test_integration_tool.py b/tests/agent/tools/test_integration_tool.py index c95f4aaae..b01e6ac68 100644 --- a/tests/agent/tools/test_integration_tool.py +++ b/tests/agent/tools/test_integration_tool.py @@ -25,6 +25,7 @@ convert_integration_parameters_to_argument_properties, convert_to_activity_metadata, create_integration_tool, + remove_asterisk_from_properties, strip_template_enums_from_schema, ) from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( @@ -1126,3 +1127,65 @@ async def test_non_400_enriched_exception_propagates( with pytest.raises(EnrichedException): await tool.ainvoke({"query": "test"}) + + +class TestRemoveAsteriskFromProperties: + """Test cases for remove_asterisk_from_properties function.""" + + def test_cleans_defs_keys(self) -> None: + """$defs keys are cleaned alongside $ref values.""" + schema = { + "type": "object", + "properties": { + "items[*]": {"$ref": "#/$defs/Record[*]"}, + }, + "$defs": { + "Record[*]": { + "type": "object", + "properties": {"name": {"type": "string"}}, + }, + }, + } + cleaned = remove_asterisk_from_properties(schema) + + assert "[*]" not in cleaned["properties"]["items"]["$ref"] + assert "Record" in cleaned["$defs"] + assert "Record[*]" not in cleaned["$defs"] + + def test_cleans_definitions_keyword(self) -> None: + """Correctly cleans keys when using 'definitions' keyword.""" + schema = { + "type": "object", + "properties": { + "items[*]": {"$ref": "#/definitions/Record[*]"}, + }, + "definitions": { + "Record[*]": { + "type": "object", + "properties": {"name": {"type": "string"}}, + }, + }, + } + cleaned = remove_asterisk_from_properties(schema) + + assert "Record" in cleaned["definitions"] + assert "Record[*]" not in cleaned["definitions"] + + def test_no_asterisks_passthrough(self) -> None: + """Schema without asterisks is returned unchanged.""" + schema = { + "type": "object", + "properties": { + "owner": {"$ref": "#/$defs/Contact"}, + }, + "$defs": { + "Contact": { + "type": "object", + "properties": {"name": {"type": "string"}}, + }, + }, + } + cleaned = remove_asterisk_from_properties(schema) + + assert cleaned["properties"]["owner"]["$ref"] == "#/$defs/Contact" + assert "Contact" in cleaned["$defs"] diff --git a/uv.lock b/uv.lock index 715b0c9ac..af7f33610 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.9.2" +version = "0.9.3" source = { editable = "." } dependencies = [ { name = "httpx" }, From 55a49b20b5539b52015030b6d8db2ae0bb8d1b1c Mon Sep 17 00:00:00 2001 From: milind-jain-uipath Date: Tue, 24 Mar 2026 14:41:45 +0530 Subject: [PATCH 29/33] removed count(*) examples --- pyproject.toml | 4 ---- .../agent/tools/datafabric_tool/datafabric_tool.py | 5 +++-- uv.lock | 2 -- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6eb8ce183..1b716997c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,6 @@ dev = [ "numpy>=1.24.0", "pytest_httpx>=0.35.0", "rust-just>=1.39.0", - "uipath-langchain", ] [tool.hatch.build.targets.wheel] @@ -119,6 +118,3 @@ name = "testpypi" url = "https://test.pypi.org/simple/" publish-url = "https://test.pypi.org/legacy/" explicit = true - -[tool.uv.sources] -uipath-langchain = { workspace = true } diff --git a/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py b/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py index 053345083..e44d64fa9 100644 --- a/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py +++ b/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py @@ -192,11 +192,12 @@ def format_schemas_for_context(entities: list[Entity]) -> str: lines.append( f"| 'Top N by Y' | `SELECT {fields_sample} FROM {sql_table} ORDER BY {agg_field} DESC LIMIT N` |" ) + count_col = field_names[0] if field_names else "id" lines.append( - f"| 'Count by X' | `SELECT {group_field}, COUNT(*) as count FROM {sql_table} GROUP BY {group_field}` |" + f"| 'Count by X' | `SELECT {group_field}, COUNT({count_col}) as count FROM {sql_table} GROUP BY {group_field}` |" ) lines.append( - f"| 'Top N segments' | `SELECT {group_field}, COUNT(*) as count FROM {sql_table} GROUP BY {group_field} ORDER BY count DESC LIMIT N` |" + f"| 'Top N segments' | `SELECT {group_field}, COUNT({count_col}) as count FROM {sql_table} GROUP BY {group_field} ORDER BY count DESC LIMIT N` |" ) lines.append( f"| 'Sum/Avg of Y' | `SELECT SUM({agg_field}) as total FROM {sql_table}` |" diff --git a/uv.lock b/uv.lock index c970a18a9..715b0c9ac 100644 --- a/uv.lock +++ b/uv.lock @@ -3378,7 +3378,6 @@ dev = [ { name = "pytest-mock" }, { name = "ruff" }, { name = "rust-just" }, - { name = "uipath-langchain" }, { name = "virtualenv" }, ] @@ -3421,7 +3420,6 @@ dev = [ { name = "pytest-mock", specifier = ">=3.11.1" }, { name = "ruff", specifier = ">=0.9.4" }, { name = "rust-just", specifier = ">=1.39.0" }, - { name = "uipath-langchain", editable = "." }, { name = "virtualenv", specifier = ">=20.36.1" }, ] From fff4b2e97d8d9fc81f93f3536ff91b88216aa532 Mon Sep 17 00:00:00 2001 From: milind-jain-uipath Date: Tue, 24 Mar 2026 17:14:23 +0530 Subject: [PATCH 30/33] removed count(*) examples + prompt change --- pyproject.toml | 4 ---- .../agent/tools/datafabric_tool/datafabric_tool.py | 5 +++-- .../agent/tools/datafabric_tool/system_prompt.txt | 11 +++++++++++ uv.lock | 2 -- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6eb8ce183..1b716997c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,6 @@ dev = [ "numpy>=1.24.0", "pytest_httpx>=0.35.0", "rust-just>=1.39.0", - "uipath-langchain", ] [tool.hatch.build.targets.wheel] @@ -119,6 +118,3 @@ name = "testpypi" url = "https://test.pypi.org/simple/" publish-url = "https://test.pypi.org/legacy/" explicit = true - -[tool.uv.sources] -uipath-langchain = { workspace = true } diff --git a/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py b/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py index 053345083..e44d64fa9 100644 --- a/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py +++ b/src/uipath_langchain/agent/tools/datafabric_tool/datafabric_tool.py @@ -192,11 +192,12 @@ def format_schemas_for_context(entities: list[Entity]) -> str: lines.append( f"| 'Top N by Y' | `SELECT {fields_sample} FROM {sql_table} ORDER BY {agg_field} DESC LIMIT N` |" ) + count_col = field_names[0] if field_names else "id" lines.append( - f"| 'Count by X' | `SELECT {group_field}, COUNT(*) as count FROM {sql_table} GROUP BY {group_field}` |" + f"| 'Count by X' | `SELECT {group_field}, COUNT({count_col}) as count FROM {sql_table} GROUP BY {group_field}` |" ) lines.append( - f"| 'Top N segments' | `SELECT {group_field}, COUNT(*) as count FROM {sql_table} GROUP BY {group_field} ORDER BY count DESC LIMIT N` |" + f"| 'Top N segments' | `SELECT {group_field}, COUNT({count_col}) as count FROM {sql_table} GROUP BY {group_field} ORDER BY count DESC LIMIT N` |" ) lines.append( f"| 'Sum/Avg of Y' | `SELECT SUM({agg_field}) as total FROM {sql_table}` |" diff --git a/src/uipath_langchain/agent/tools/datafabric_tool/system_prompt.txt b/src/uipath_langchain/agent/tools/datafabric_tool/system_prompt.txt index 03df458fb..aed1e659d 100644 --- a/src/uipath_langchain/agent/tools/datafabric_tool/system_prompt.txt +++ b/src/uipath_langchain/agent/tools/datafabric_tool/system_prompt.txt @@ -121,4 +121,15 @@ UNSUPPORTED SCENARIOS (Avoid these patterns): - JSON/ARRAY/MAP/GEOMETRY/BLOB operations - Complex timezone-aware timestamp operations +RETRY BEHAVIOR: +If a query fails with a validation error (e.g. missing LIMIT, SELECT *, COUNT(*)), DO NOT give up. Instead: +1. Read the error message carefully +2. Fix the query to comply with the constraint +3. Call query_datafabric again with the corrected query + +Example: if "Queries without WHERE must include a LIMIT clause" is returned, add LIMIT 100 and retry: + Before: SELECT name, department FROM Employee + After: SELECT name, department FROM Employee LIMIT 100 + + Return only the SQL query as plain text. \ No newline at end of file diff --git a/uv.lock b/uv.lock index c970a18a9..715b0c9ac 100644 --- a/uv.lock +++ b/uv.lock @@ -3378,7 +3378,6 @@ dev = [ { name = "pytest-mock" }, { name = "ruff" }, { name = "rust-just" }, - { name = "uipath-langchain" }, { name = "virtualenv" }, ] @@ -3421,7 +3420,6 @@ dev = [ { name = "pytest-mock", specifier = ">=3.11.1" }, { name = "ruff", specifier = ">=0.9.4" }, { name = "rust-just", specifier = ">=1.39.0" }, - { name = "uipath-langchain", editable = "." }, { name = "virtualenv", specifier = ">=20.36.1" }, ] From 46ef48390dfb6b76de615bb03d38d42a2b864c71 Mon Sep 17 00:00:00 2001 From: Radu Mocanu Date: Tue, 24 Mar 2026 14:47:17 +0200 Subject: [PATCH 31/33] fix: pass sys ssl certificates to bedrock client (#718) --- pyproject.toml | 4 +- src/uipath_langchain/chat/bedrock.py | 10 +++- tests/chat/test_bedrock.py | 79 ++++++++++++++++++++++++++++ uv.lock | 10 ++-- 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 65353c588..680fb7852 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath-langchain" -version = "0.9.3" +version = "0.9.4" 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.10.22, <2.11.0", "uipath-core>=0.5.2, <0.6.0", - "uipath-platform>=0.1.5, <0.2.0", + "uipath-platform>=0.1.7, <0.2.0", "uipath-runtime>=0.9.1, <0.10.0", "langgraph>=1.0.0, <2.0.0", "langchain-core>=1.2.11, <2.0.0", diff --git a/src/uipath_langchain/chat/bedrock.py b/src/uipath_langchain/chat/bedrock.py index 7c5666c09..aa53aa7ed 100644 --- a/src/uipath_langchain/chat/bedrock.py +++ b/src/uipath_langchain/chat/bedrock.py @@ -7,7 +7,11 @@ from langchain_core.messages import BaseMessage from langchain_core.outputs import ChatGenerationChunk, ChatResult from tenacity import AsyncRetrying, Retrying -from uipath.platform.common import EndpointManager, resource_override +from uipath.platform.common import ( + EndpointManager, + get_ca_bundle_path, + resource_override, +) from .http_client import build_uipath_headers, resolve_gateway_url from .http_client.header_capture import HeaderCapture @@ -110,8 +114,10 @@ def _unsigned_config(self, **overrides): def get_client(self): session = self._build_session() + ca_bundle = get_ca_bundle_path() client = session.client( "bedrock-runtime", + verify=ca_bundle if ca_bundle is not None else False, config=self._unsigned_config( retries={"total_max_attempts": 1}, read_timeout=300, @@ -127,8 +133,10 @@ def get_client(self): def get_bedrock_client(self): session = self._build_session() + ca_bundle = get_ca_bundle_path() return session.client( "bedrock", + verify=ca_bundle if ca_bundle is not None else False, config=self._unsigned_config(), ) diff --git a/tests/chat/test_bedrock.py b/tests/chat/test_bedrock.py index e4b15431f..49db282a6 100644 --- a/tests/chat/test_bedrock.py +++ b/tests/chat/test_bedrock.py @@ -167,3 +167,82 @@ def test_generate_converts_file_blocks(self, mock_session_cls): }, } assert result == fake_result + + +class TestBedrockSslConfiguration: + def _make_passthrough(self): + return AwsBedrockCompletionsPassthroughClient( + model="anthropic.claude-haiku-4-5-20251001", + token="test-token", + api_flavor="converse", + ) + + @patch("uipath_langchain.chat.bedrock.boto3.Session") + @patch.dict(os.environ, {"SSL_CERT_FILE": "/tmp/test-ca-bundle.pem"}, clear=False) + def test_get_client_uses_ssl_cert_file(self, mock_session_cls): + os.environ.pop("REQUESTS_CA_BUNDLE", None) + os.environ.pop("UIPATH_DISABLE_SSL_VERIFY", None) + mock_session = mock_session_cls.return_value + + self._make_passthrough().get_client() + + mock_session.client.assert_called_once() + _, kwargs = mock_session.client.call_args + assert kwargs["verify"] == "/tmp/test-ca-bundle.pem" + + @patch("uipath_langchain.chat.bedrock.boto3.Session") + @patch.dict(os.environ, {"SSL_CERT_FILE": "/tmp/test-ca-bundle.pem"}, clear=False) + def test_get_bedrock_client_uses_ssl_cert_file(self, mock_session_cls): + os.environ.pop("REQUESTS_CA_BUNDLE", None) + os.environ.pop("UIPATH_DISABLE_SSL_VERIFY", None) + mock_session = mock_session_cls.return_value + + self._make_passthrough().get_bedrock_client() + + mock_session.client.assert_called_once() + _, kwargs = mock_session.client.call_args + assert kwargs["verify"] == "/tmp/test-ca-bundle.pem" + + @patch("uipath_langchain.chat.bedrock.boto3.Session") + @patch.dict( + os.environ, + { + "SSL_CERT_FILE": "/tmp/ssl-cert.pem", + "REQUESTS_CA_BUNDLE": "/tmp/requests-ca.pem", + }, + clear=False, + ) + def test_ssl_cert_file_takes_priority_over_requests_ca_bundle( + self, mock_session_cls + ): + os.environ.pop("UIPATH_DISABLE_SSL_VERIFY", None) + mock_session = mock_session_cls.return_value + + self._make_passthrough().get_client() + + _, kwargs = mock_session.client.call_args + assert kwargs["verify"] == "/tmp/ssl-cert.pem" + + @patch("uipath_langchain.chat.bedrock.boto3.Session") + @patch.dict(os.environ, {"UIPATH_DISABLE_SSL_VERIFY": "true"}, clear=False) + def test_disable_ssl_verify(self, mock_session_cls): + mock_session = mock_session_cls.return_value + + self._make_passthrough().get_client() + + _, kwargs = mock_session.client.call_args + assert kwargs["verify"] is False + + @patch("uipath_langchain.chat.bedrock.boto3.Session") + def test_default_uses_certifi(self, mock_session_cls): + import certifi + + os.environ.pop("SSL_CERT_FILE", None) + os.environ.pop("REQUESTS_CA_BUNDLE", None) + os.environ.pop("UIPATH_DISABLE_SSL_VERIFY", None) + mock_session = mock_session_cls.return_value + + self._make_passthrough().get_client() + + _, kwargs = mock_session.client.call_args + assert kwargs["verify"] == certifi.where() diff --git a/uv.lock b/uv.lock index af7f33610..699e36d80 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.9.3" +version = "0.9.4" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -3402,7 +3402,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "uipath", specifier = ">=2.10.22,<2.11.0" }, { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, - { name = "uipath-platform", specifier = ">=0.1.5,<0.2.0" }, + { name = "uipath-platform", specifier = ">=0.1.7,<0.2.0" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, ] provides-extras = ["vertex", "bedrock"] @@ -3425,7 +3425,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.5" +version = "0.1.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3435,9 +3435,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/b3/4cf64b6be903316b513ba094a5357c0d5e1be58f0d6ea837c06ae5e32092/uipath_platform-0.1.5.tar.gz", hash = "sha256:9b04eebe261591fb6c138067d427fa9b1554a3e38fef1ab508c4711f41861999", size = 285153, upload-time = "2026-03-23T22:40:05.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/28/cbfeb9245ef65c4f1126d6daa9752afa27b2510659515d2fc05ff1327017/uipath_platform-0.1.7.tar.gz", hash = "sha256:a3bd7e9c48ac16f98b6fc933ebf2f620b27af9a9373c8c06d568651ef74e20c6", size = 285344, upload-time = "2026-03-24T12:41:26.961Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/c3/1beebcf261777945daf2633a8c9a03a86545415032dac208d369fef54412/uipath_platform-0.1.5-py3-none-any.whl", hash = "sha256:0bbe17f8afa06d82a5d196a7b75a308fc2765cee1f5830ba64ad660977ebf162", size = 176100, upload-time = "2026-03-23T22:40:04.147Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d8/609771309735523af1c51bd5787759156ef3dd15999fd87eaba84934f40f/uipath_platform-0.1.7-py3-none-any.whl", hash = "sha256:b0f9d6a8affdade2119bc51b593dfecb42278def3b65ffa5a9d8b5a3bbc04a74", size = 176339, upload-time = "2026-03-24T12:41:25.493Z" }, ] [[package]] From 11ec11308c9a5248e8464fb9e769f0a6f16a757d Mon Sep 17 00:00:00 2001 From: Gabriel Martin <91462031+gcuip@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:13:29 +0100 Subject: [PATCH 32/33] chore: bump uipath 2.10.29 (#723) --- pyproject.toml | 4 ++-- uv.lock | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 680fb7852..e7ea2bab9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-langchain" -version = "0.9.4" +version = "0.9.5" 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.10.22, <2.11.0", + "uipath>=2.10.29, <2.11.0", "uipath-core>=0.5.2, <0.6.0", "uipath-platform>=0.1.7, <0.2.0", "uipath-runtime>=0.9.1, <0.10.0", diff --git a/uv.lock b/uv.lock index 699e36d80..0f274b7b2 100644 --- a/uv.lock +++ b/uv.lock @@ -3289,7 +3289,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.22" +version = "2.10.29" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -3312,9 +3312,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/a2/694484060d07a25c27541d31e9b6bc37f693754b36e4ad1f29d2379dd6a7/uipath-2.10.22.tar.gz", hash = "sha256:7f55aae793c667720c490535c9a78705a322218695afb532d3a79545b313af60", size = 2461940, upload-time = "2026-03-20T10:00:11.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/ad/e1114442907720ece604a4a32f28616e84f09728ad2cc373c3cd43a484d8/uipath-2.10.29.tar.gz", hash = "sha256:5b028dbba4e7259d43f8353b89ca11efda3ecab7bea2063873054774b3379882", size = 2873141, upload-time = "2026-03-24T14:54:52.416Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/ab/b5068237c914c7cc6f8e2f49292fe3672eff0e11c716b41c667d7e7f74d3/uipath-2.10.22-py3-none-any.whl", hash = "sha256:85e80d84689e75bcff6fd8312f26604294ed96e22a1b39d64b6075b49f43933c", size = 358922, upload-time = "2026-03-20T10:00:10.004Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/107237431afb75b16f4a5d9b653f2d67d96a99a75b361379d8b02fb2725b/uipath-2.10.29-py3-none-any.whl", hash = "sha256:0b87f0436157028697210fd1c677a701bf5da1d1d1f18468e82134a19d76dda9", size = 360467, upload-time = "2026-03-24T14:54:50.564Z" }, ] [[package]] @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.9.4" +version = "0.9.5" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -3400,7 +3400,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.10.22,<2.11.0" }, + { name = "uipath", specifier = ">=2.10.29,<2.11.0" }, { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, { name = "uipath-platform", specifier = ">=0.1.7,<0.2.0" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, From 8ff44124960a732139b27595376700becb2c7fcf Mon Sep 17 00:00:00 2001 From: Cosmin MARIA Date: Tue, 24 Mar 2026 17:47:11 +0200 Subject: [PATCH 33/33] Fix/fix anthropic payload handler (#724) --- pyproject.toml | 2 +- src/uipath_langchain/chat/handlers/bedrock.py | 12 ++-- tests/chat/test_bedrock_payload_handler.py | 67 +++++++++++++++++++ uv.lock | 2 +- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e7ea2bab9..f106d0cd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.9.5" +version = "0.9.6" 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/chat/handlers/bedrock.py b/src/uipath_langchain/chat/handlers/bedrock.py index 65be087d0..5ff44d872 100644 --- a/src/uipath_langchain/chat/handlers/bedrock.py +++ b/src/uipath_langchain/chat/handlers/bedrock.py @@ -86,9 +86,9 @@ def get_tool_binding_kwargs( parallel_tool_calls: bool = True, strict_mode: bool = False, ) -> dict[str, Any]: + _thinking = (getattr(self.model, "model_kwargs", None) or {}).get("thinking") thinking_enabled = ( - getattr(self.model, "model_kwargs", {}).get("thinking", {}).get("type") - == "enabled" + isinstance(_thinking, dict) and _thinking.get("type") == "enabled" ) # Anthropic models via Invoke API don't support forced tool use with extended thinking if thinking_enabled and tool_choice == "any": @@ -142,11 +142,11 @@ def get_tool_binding_kwargs( parallel_tool_calls: bool = True, strict_mode: bool = False, ) -> dict[str, Any]: + _thinking = ( + getattr(self.model, "additional_model_request_fields", None) or {} + ).get("thinking") thinking_enabled = ( - getattr(self.model, "additional_model_request_fields", {}) - .get("thinking", {}) - .get("type") - == "enabled" + isinstance(_thinking, dict) and _thinking.get("type") == "enabled" ) # Anthropic models via Converse API don't support forced tool use with extended thinking if thinking_enabled and tool_choice == "any": diff --git a/tests/chat/test_bedrock_payload_handler.py b/tests/chat/test_bedrock_payload_handler.py index d10429ad9..02fdca468 100644 --- a/tests/chat/test_bedrock_payload_handler.py +++ b/tests/chat/test_bedrock_payload_handler.py @@ -231,3 +231,70 @@ def test_invoke_snake_case_key_ignored(self) -> None: """Converse handler must not react to stop_reason (snake_case).""" msg = AIMessage(content="", response_metadata={"stop_reason": "max_tokens"}) self.handler.check_stop_reason(msg) # should not raise + + +# --------------------------------------------------------------------------- +# Null-safety: thinking_enabled detection +# --------------------------------------------------------------------------- + + +class TestBedrockInvokeThinkingNullSafety: + """model_kwargs or its nested values may be None — must not raise.""" + + def test_model_kwargs_is_none(self) -> None: + model = type("FakeChatBedrock", (), {})() + model.model_kwargs = None + handler = BedrockInvokePayloadHandler(model) + result = handler.get_tool_binding_kwargs([], "any") + assert result["tool_choice"] == "any" + + def test_thinking_value_is_none(self) -> None: + model = type("FakeChatBedrock", (), {})() + model.model_kwargs = {"thinking": None} + handler = BedrockInvokePayloadHandler(model) + result = handler.get_tool_binding_kwargs([], "any") + assert result["tool_choice"] == "any" + + def test_model_kwargs_attribute_missing(self) -> None: + model = type("FakeChatBedrock", (), {})() + handler = BedrockInvokePayloadHandler(model) + result = handler.get_tool_binding_kwargs([], "any") + assert result["tool_choice"] == "any" + + def test_thinking_value_is_non_dict_truthy(self) -> None: + model = type("FakeChatBedrock", (), {})() + model.model_kwargs = {"thinking": "enabled"} + handler = BedrockInvokePayloadHandler(model) + result = handler.get_tool_binding_kwargs([], "any") + assert result["tool_choice"] == "any" + + +class TestBedrockConverseThinkingNullSafety: + """additional_model_request_fields or its nested values may be None — must not raise.""" + + def test_additional_fields_is_none(self) -> None: + model = type("FakeChatBedrockConverse", (), {})() + model.additional_model_request_fields = None + handler = BedrockConversePayloadHandler(model) + result = handler.get_tool_binding_kwargs([], "any") + assert result["tool_choice"] == "any" + + def test_thinking_value_is_none(self) -> None: + model = type("FakeChatBedrockConverse", (), {})() + model.additional_model_request_fields = {"thinking": None} + handler = BedrockConversePayloadHandler(model) + result = handler.get_tool_binding_kwargs([], "any") + assert result["tool_choice"] == "any" + + def test_thinking_value_is_non_dict_truthy(self) -> None: + model = type("FakeChatBedrockConverse", (), {})() + model.additional_model_request_fields = {"thinking": "enabled"} + handler = BedrockConversePayloadHandler(model) + result = handler.get_tool_binding_kwargs([], "any") + assert result["tool_choice"] == "any" + + def test_additional_fields_attribute_missing(self) -> None: + model = type("FakeChatBedrockConverse", (), {})() + handler = BedrockConversePayloadHandler(model) + result = handler.get_tool_binding_kwargs([], "any") + assert result["tool_choice"] == "any" diff --git a/uv.lock b/uv.lock index 0f274b7b2..23fb3df6a 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.9.5" +version = "0.9.6" source = { editable = "." } dependencies = [ { name = "httpx" },