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] 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"]