From 557c32fdda21abb02c8eea4e99540b1482e7b9e8 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Wed, 27 May 2026 14:36:24 -0400 Subject: [PATCH 1/4] feat: pass in reserved conversation id variable from environment to process tools --- src/uipath_langchain/_utils/__init__.py | 3 +- src/uipath_langchain/_utils/_environment.py | 5 + .../agent/tools/process_tool.py | 7 +- tests/agent/tools/test_process_tool.py | 146 ++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) diff --git a/src/uipath_langchain/_utils/__init__.py b/src/uipath_langchain/_utils/__init__.py index 7a2e483c5..4073fc845 100644 --- a/src/uipath_langchain/_utils/__init__.py +++ b/src/uipath_langchain/_utils/__init__.py @@ -1,4 +1,4 @@ -from ._environment import get_execution_folder_path +from ._environment import get_conversation_id, get_execution_folder_path from ._otel import ( get_current_span_and_trace_ids, set_current_span_error, @@ -8,6 +8,7 @@ __all__ = [ "UiPathRequestMixin", + "get_conversation_id", "get_current_span_and_trace_ids", "get_execution_folder_path", "set_current_span_error", diff --git a/src/uipath_langchain/_utils/_environment.py b/src/uipath_langchain/_utils/_environment.py index 60be18ff1..8816337a2 100644 --- a/src/uipath_langchain/_utils/_environment.py +++ b/src/uipath_langchain/_utils/_environment.py @@ -6,5 +6,10 @@ def get_execution_folder_path() -> str | None: return os.environ.get("UIPATH_FOLDER_PATH") +def get_conversation_id() -> str | None: + """Reads the current conversation ID from the runtime environment.""" + return os.environ.get("UIPATH_CONVERSATION_ID") + + def get_default_timeout() -> float: return float(os.getenv("UIPATH_TIMEOUT_SECONDS", "895")) diff --git a/src/uipath_langchain/agent/tools/process_tool.py b/src/uipath_langchain/agent/tools/process_tool.py index 962f6d349..6373028cc 100644 --- a/src/uipath_langchain/agent/tools/process_tool.py +++ b/src/uipath_langchain/agent/tools/process_tool.py @@ -12,7 +12,7 @@ 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 import get_conversation_id, 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 @@ -38,6 +38,7 @@ ), } +_RESERVED_CONVERSATION_ID_KEY = "UIPATH_RESERVED_CONVERSATIONID" def create_process_tool( resource: AgentProcessToolResourceConfig, @@ -58,6 +59,10 @@ def create_process_tool( _bts_context: dict[str, Any] = {} async def process_tool_fn(**kwargs: Any): + if _RESERVED_CONVERSATION_ID_KEY in input_model.model_fields: + conversation_id = get_conversation_id() + if conversation_id is not None: + kwargs[_RESERVED_CONVERSATION_ID_KEY] = conversation_id attachments = get_job_attachments(input_model, kwargs) input_arguments = input_model.model_validate(kwargs).model_dump(mode="json") diff --git a/tests/agent/tools/test_process_tool.py b/tests/agent/tools/test_process_tool.py index ea1778c58..54e9efa1e 100644 --- a/tests/agent/tools/test_process_tool.py +++ b/tests/agent/tools/test_process_tool.py @@ -550,3 +550,149 @@ async def test_flow_tool_uses_non_agent_bts_key( bts_context = tool.metadata["_bts_context"] assert bts_context.get("wait_for_job_key") == "flow-job-key" assert "wait_for_agent_job_key" not in bts_context + + +@pytest.fixture +def process_resource_with_conversation_id(): + """Resource whose input schema declares the reserved conversation-id arg.""" + return AgentProcessToolResourceConfig( + type=AgentToolType.PROCESS, + name="conv_process", + description="Process that consumes conversation id", + input_schema={ + "type": "object", + "properties": { + "topic": {"type": "string"}, + "UIPATH_RESERVED_CONVERSATIONID": {"type": "string"}, + }, + }, + output_schema={"type": "object", "properties": {}}, + properties=AgentProcessToolProperties( + process_name="ConvProcess", + folder_path="/Shared/Conv", + ), + ) + + +class TestProcessToolConversationIdInjection: + """Auto-inject conversation id when the tool's input schema declares it.""" + + @pytest.mark.asyncio + @patch.dict(os.environ, {"UIPATH_CONVERSATION_ID": "conv-xyz"}) + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain.agent.tools.process_tool.UiPath") + async def test_injects_conversation_id_when_schema_declares_it( + self, + mock_uipath_class, + mock_interrupt, + process_resource_with_conversation_id, + ): + mock_job = MagicMock(spec=Job) + mock_job.key = "job-key" + mock_job.folder_key = "folder-key" + mock_resumed_job = MagicMock(spec=Job) + mock_resumed_job.state = "successful" + + mock_client = MagicMock() + mock_client.processes.invoke_async = AsyncMock(return_value=mock_job) + mock_client.jobs.extract_output_async = AsyncMock(return_value=None) + mock_uipath_class.return_value = mock_client + mock_interrupt.return_value = mock_resumed_job + + tool = create_process_tool(process_resource_with_conversation_id) + await tool.ainvoke({"topic": "hello"}) + + call_kwargs = mock_client.processes.invoke_async.call_args[1] + assert call_kwargs["input_arguments"] == { + "topic": "hello", + "UIPATH_RESERVED_CONVERSATIONID": "conv-xyz", + } + + @pytest.mark.asyncio + @patch.dict(os.environ, {"UIPATH_CONVERSATION_ID": "from-runtime"}) + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain.agent.tools.process_tool.UiPath") + async def test_runtime_value_overrides_caller_supplied_value( + self, + mock_uipath_class, + mock_interrupt, + process_resource_with_conversation_id, + ): + mock_job = MagicMock(spec=Job) + mock_job.key = "job-key" + mock_job.folder_key = "folder-key" + mock_resumed_job = MagicMock(spec=Job) + mock_resumed_job.state = "successful" + + mock_client = MagicMock() + mock_client.processes.invoke_async = AsyncMock(return_value=mock_job) + mock_client.jobs.extract_output_async = AsyncMock(return_value=None) + mock_uipath_class.return_value = mock_client + mock_interrupt.return_value = mock_resumed_job + + tool = create_process_tool(process_resource_with_conversation_id) + await tool.ainvoke( + {"topic": "hi", "UIPATH_RESERVED_CONVERSATIONID": "from-llm"} + ) + + call_kwargs = mock_client.processes.invoke_async.call_args[1] + assert ( + call_kwargs["input_arguments"]["UIPATH_RESERVED_CONVERSATIONID"] + == "from-runtime" + ) + + @pytest.mark.asyncio + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain.agent.tools.process_tool.UiPath") + async def test_omits_when_conversation_id_missing( + self, + mock_uipath_class, + mock_interrupt, + process_resource_with_conversation_id, + ): + os.environ.pop("UIPATH_CONVERSATION_ID", None) + mock_job = MagicMock(spec=Job) + mock_job.key = "job-key" + mock_job.folder_key = "folder-key" + mock_resumed_job = MagicMock(spec=Job) + mock_resumed_job.state = "successful" + + mock_client = MagicMock() + mock_client.processes.invoke_async = AsyncMock(return_value=mock_job) + mock_client.jobs.extract_output_async = AsyncMock(return_value=None) + mock_uipath_class.return_value = mock_client + mock_interrupt.return_value = mock_resumed_job + + tool = create_process_tool(process_resource_with_conversation_id) + await tool.ainvoke({"topic": "hi"}) + + call_kwargs = mock_client.processes.invoke_async.call_args[1] + assert call_kwargs["input_arguments"].get("UIPATH_RESERVED_CONVERSATIONID") is None + + @pytest.mark.asyncio + @patch.dict(os.environ, {"UIPATH_CONVERSATION_ID": "conv-xyz"}) + @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") + @patch("uipath_langchain.agent.tools.process_tool.UiPath") + async def test_skips_injection_when_schema_does_not_declare_it( + self, + mock_uipath_class, + mock_interrupt, + process_resource_with_inputs, + ): + mock_job = MagicMock(spec=Job) + mock_job.key = "job-key" + mock_job.folder_key = "folder-key" + mock_resumed_job = MagicMock(spec=Job) + mock_resumed_job.state = "successful" + + mock_client = MagicMock() + mock_client.processes.invoke_async = AsyncMock(return_value=mock_job) + mock_client.jobs.extract_output_async = AsyncMock(return_value=None) + mock_uipath_class.return_value = mock_client + mock_interrupt.return_value = mock_resumed_job + + tool = create_process_tool(process_resource_with_inputs) + await tool.ainvoke({"name": "x", "count": 1}) + + call_kwargs = mock_client.processes.invoke_async.call_args[1] + assert "UIPATH_RESERVED_CONVERSATIONID" not in call_kwargs["input_arguments"] From 3079dcc5d5b854e579f9b93eef5d51b715a79160 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:13:02 -0500 Subject: [PATCH 2/4] fix: undo spacing changes --- tests/agent/tools/test_process_tool.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/agent/tools/test_process_tool.py b/tests/agent/tools/test_process_tool.py index d55404808..7926c8d16 100644 --- a/tests/agent/tools/test_process_tool.py +++ b/tests/agent/tools/test_process_tool.py @@ -740,6 +740,7 @@ async def test_function_tool_invokes_processes_invoke_async( mock_job = MagicMock(spec=Job) mock_job.key = "function-job-key" mock_job.folder_key = "function-folder-key" + mock_resumed_job = MagicMock(spec=Job) mock_resumed_job.state = "successful" @@ -747,6 +748,7 @@ async def test_function_tool_invokes_processes_invoke_async( mock_client.processes.invoke_async = AsyncMock(return_value=mock_job) mock_client.jobs.extract_output_async = AsyncMock(return_value=None) mock_uipath_class.return_value = mock_client + mock_interrupt.return_value = mock_resumed_job tool = create_process_tool(function_resource) @@ -772,6 +774,7 @@ async def test_function_tool_uses_non_agent_bts_key( mock_job = MagicMock(spec=Job) mock_job.key = "function-job-key" mock_job.folder_key = "function-folder-key" + mock_resumed_job = MagicMock(spec=Job) mock_resumed_job.state = "successful" @@ -779,6 +782,7 @@ async def test_function_tool_uses_non_agent_bts_key( mock_client.processes.invoke_async = AsyncMock(return_value=mock_job) mock_client.jobs.extract_output_async = AsyncMock(return_value=None) mock_uipath_class.return_value = mock_client + mock_interrupt.return_value = mock_resumed_job tool = create_process_tool(function_resource) From d91f3f1714cd25ba24f55663c3edc857b9bf2917 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:13:37 -0500 Subject: [PATCH 3/4] fix: upgrade langchain --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8053cf3b8..912f5da22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.11.12" +version = "0.11.13" 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 063f61144..dfe032b3f 100644 --- a/uv.lock +++ b/uv.lock @@ -4388,7 +4388,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.11.12" +version = "0.11.13" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, From b756ffc508d4baf2c18c8ed3a002b0ca3274eb86 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:00:03 -0500 Subject: [PATCH 4/4] chore: add logging --- src/uipath_langchain/agent/tools/process_tool.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/uipath_langchain/agent/tools/process_tool.py b/src/uipath_langchain/agent/tools/process_tool.py index 6373028cc..5f0569ac2 100644 --- a/src/uipath_langchain/agent/tools/process_tool.py +++ b/src/uipath_langchain/agent/tools/process_tool.py @@ -1,6 +1,7 @@ """Process tool creation for UiPath process execution.""" import json +import logging from typing import Any from langchain_core.tools import StructuredTool @@ -38,6 +39,8 @@ ), } +logger = logging.getLogger(__name__) + _RESERVED_CONVERSATION_ID_KEY = "UIPATH_RESERVED_CONVERSATIONID" def create_process_tool( @@ -62,6 +65,12 @@ async def process_tool_fn(**kwargs: Any): if _RESERVED_CONVERSATION_ID_KEY in input_model.model_fields: conversation_id = get_conversation_id() if conversation_id is not None: + logger.info( + "Overriding %s on tool '%s' to be '%s'", + _RESERVED_CONVERSATION_ID_KEY, + resource.name, + conversation_id, + ) kwargs[_RESERVED_CONVERSATION_ID_KEY] = conversation_id attachments = get_job_attachments(input_model, kwargs) input_arguments = input_model.model_validate(kwargs).model_dump(mode="json")