Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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)
Comment on lines +660 to +669
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