Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/uipath_langchain/_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -8,6 +8,7 @@

__all__ = [
"UiPathRequestMixin",
"get_conversation_id",
"get_current_span_and_trace_ids",
"get_execution_folder_path",
"set_current_span_error",
Expand Down
5 changes: 5 additions & 0 deletions src/uipath_langchain/_utils/_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
7 changes: 6 additions & 1 deletion src/uipath_langchain/agent/tools/process_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +38,7 @@
),
}

_RESERVED_CONVERSATION_ID_KEY = "UIPATH_RESERVED_CONVERSATIONID"

def create_process_tool(
resource: AgentProcessToolResourceConfig,
Expand All @@ -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")

Expand Down
146 changes: 146 additions & 0 deletions tests/agent/tools/test_process_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading