Skip to content
Closed
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-langchain"
version = "0.14.15"
version = "0.14.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"
Expand Down
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 @@ -44,7 +44,7 @@

def create_process_tool(
resource: AgentProcessToolResourceConfig,
run_as_me: bool = False,
conversational_run_as_me: bool = False,
) -> StructuredTool:
"""Uses interrupt() to suspend graph execution until process completes (handled by runtime)."""
# Import here to avoid circular dependency
Expand All @@ -57,6 +57,11 @@ def create_process_tool(
input_model: Any = create_model(resource.input_schema)
output_model: Any = create_output_model(resource.output_schema, resource.name)

# For conversational-agents running with RunAsMe=true, propagate RunAsMe=true
# to the process tool job as well. RPA Workflows remain unattended until
# attended local robot execution on the user's desktop is implemented.
run_as_me = conversational_run_as_me and resource.type != AgentToolType.PROCESS

_span_context: dict[str, Any] = {}
_bts_context: dict[str, Any] = {}

Expand Down
44 changes: 18 additions & 26 deletions src/uipath_langchain/agent/tools/tool_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,35 +31,22 @@
logger = getLogger(__name__)


def _is_user_token() -> bool:
"""Check if the current token is a user token (sub_type == 'user')."""
try:
from uipath._cli._utils._common import get_claim_from_token

sub_type = get_claim_from_token("sub_type")
logger.info("Token sub_type=%r", sub_type)
return sub_type == "user"
except Exception as e:
logger.info("Token sub_type check failed: %s", e)
return False


async def create_tools_from_resources(
agent: LowCodeAgentDefinition, llm: BaseChatModel
agent: LowCodeAgentDefinition,
llm: BaseChatModel,
conversational_run_as_me: bool = False,
) -> list[BaseTool]:

"""
Create tools for the agent. conversational_run_as_me is true when the tools are for
a conversational agent running as the user-identity (the agent was started with RunAsMe=true).
"""
tools: list[BaseTool] = []
is_user = _is_user_token()
run_as_me = agent.is_conversational and is_user
logger.info(
"RunAsMe decision: is_conversational=%s, is_user_token=%s, run_as_me=%s",
agent.is_conversational,
is_user,
run_as_me,
"Creating tools for agent '%s' (conversational_run_as_me=%s)",
agent.name,
conversational_run_as_me,
)

logger.info("Creating tools for agent '%s' from resources", agent.name)

for resource in agent.resources:
if not resource.is_enabled:
logger.info(
Expand All @@ -75,7 +62,10 @@ async def create_tools_from_resources(
type(resource).__name__,
)
tool = await _build_tool_for_resource(
resource, llm, agent=agent, run_as_me=run_as_me
resource,
llm,
agent=agent,
conversational_run_as_me=conversational_run_as_me,
)
if tool is not None:
if isinstance(tool, list):
Expand All @@ -99,10 +89,12 @@ async def _build_tool_for_resource(
resource: BaseAgentResourceConfig,
llm: BaseChatModel,
agent: LowCodeAgentDefinition | None = None,
run_as_me: bool = False,
conversational_run_as_me: bool = False,
) -> BaseTool | list[BaseTool] | None:
if isinstance(resource, AgentProcessToolResourceConfig):
return create_process_tool(resource, run_as_me=run_as_me)
return create_process_tool(
resource, conversational_run_as_me=conversational_run_as_me
)

elif isinstance(resource, AgentContextResourceConfig):
return create_context_tool(resource, llm=llm, agent=agent)
Expand Down
50 changes: 40 additions & 10 deletions tests/agent/tools/test_process_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,18 +394,18 @@ async def test_span_context_defaults_to_none_when_empty(


class TestProcessToolRunAsMe:
"""Test RunAsMe propagation passed top-down from tool factory."""
"""RunAsMe propagation: honored for non-RPA types, suppressed for PROCESS (RPA)."""

@pytest.mark.asyncio
@patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt")
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
async def test_run_as_me_true_passed_to_invoke(
async def test_conversational_run_as_me_true_forwards_for_non_rpa(
self,
mock_uipath_class,
mock_interrupt,
process_resource,
flow_resource,
):
"""Test RunAsMe=True is forwarded to invoke_async when set."""
"""conversational_run_as_me=True is forwarded to invoke_async for non-RPA types."""
mock_job = MagicMock(spec=Job)
mock_job.key = "job-key"
mock_job.folder_key = "folder-key"
Expand All @@ -420,7 +420,7 @@ async def test_run_as_me_true_passed_to_invoke(

mock_interrupt.return_value = mock_resumed_job

tool = create_process_tool(process_resource, run_as_me=True)
tool = create_process_tool(flow_resource, conversational_run_as_me=True)
await tool.ainvoke({})

call_kwargs = mock_client.processes.invoke_async.call_args[1]
Expand All @@ -429,13 +429,43 @@ async def test_run_as_me_true_passed_to_invoke(
@pytest.mark.asyncio
@patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt")
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
async def test_run_as_me_false_sends_none(
async def test_conversational_run_as_me_true_suppressed_for_rpa_process(
self,
mock_uipath_class,
mock_interrupt,
process_resource,
):
"""RPA (PROCESS) suppresses RunAsMe even when the caller sets it."""
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, conversational_run_as_me=True)
await tool.ainvoke({})

call_kwargs = mock_client.processes.invoke_async.call_args[1]
assert call_kwargs["run_as_me"] is None

@pytest.mark.asyncio
@patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt")
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
async def test_conversational_run_as_me_false_sends_none(
self,
mock_uipath_class,
mock_interrupt,
process_resource,
):
"""Test RunAsMe=None when run_as_me=False (default)."""
"""RunAsMe=None when conversational_run_as_me=False."""
mock_job = MagicMock(spec=Job)
mock_job.key = "job-key"
mock_job.folder_key = "folder-key"
Expand All @@ -450,7 +480,7 @@ async def test_run_as_me_false_sends_none(

mock_interrupt.return_value = mock_resumed_job

tool = create_process_tool(process_resource, run_as_me=False)
tool = create_process_tool(process_resource, conversational_run_as_me=False)
await tool.ainvoke({})

call_kwargs = mock_client.processes.invoke_async.call_args[1]
Expand All @@ -459,13 +489,13 @@ async def test_run_as_me_false_sends_none(
@pytest.mark.asyncio
@patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt")
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
async def test_run_as_me_default_sends_none(
async def test_conversational_run_as_me_default_sends_none(
self,
mock_uipath_class,
mock_interrupt,
process_resource,
):
"""Test RunAsMe=None when run_as_me not specified (default)."""
"""RunAsMe=None when conversational_run_as_me not specified (default)."""
mock_job = MagicMock(spec=Job)
mock_job.key = "job-key"
mock_job.folder_key = "folder-key"
Expand Down
62 changes: 60 additions & 2 deletions tests/agent/tools/test_tool_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,9 @@ async def test_flow_resource_routes_through_process_tool_path(
)
tool = await _build_tool_for_resource(flow_resource, mock_llm)

mock_create_process_tool.assert_called_once_with(flow_resource, run_as_me=False)
mock_create_process_tool.assert_called_once_with(
flow_resource, conversational_run_as_me=False
)
assert tool is not None

async def test_quick_form_resource_routes_through_escalation_tool_path(
Expand Down Expand Up @@ -467,10 +469,66 @@ async def test_function_resource_routes_through_process_tool_path(
tool = await _build_tool_for_resource(function_resource, mock_llm)

mock_create_process_tool.assert_called_once_with(
function_resource, run_as_me=False
function_resource, conversational_run_as_me=False
)
assert tool is not None

async def test_conversational_run_as_me_forwarded_to_process_tool(
self, process_resource, mock_uipath_sdk
):
"""The dispatcher forwards conversational_run_as_me unchanged.

The per-type suppression rule (RPA / PROCESS ignores RunAsMe) lives
inside ``create_process_tool``; see ``test_process_tool.py``.
"""
process_resource.is_enabled = True
agent = LowCodeAgentDefinition(
input_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
messages=[],
settings=Mock(spec=AgentSettings),
resources=[process_resource],
)
mock_llm = AsyncMock(spec=BaseChatModel)
with patch(
"uipath_langchain.agent.tools.tool_factory.create_process_tool"
) as mock_create_process_tool:
mock_create_process_tool.return_value = MagicMock(
spec=BaseUiPathStructuredTool
)
await create_tools_from_resources(
agent, mock_llm, conversational_run_as_me=True
)

mock_create_process_tool.assert_called_once_with(
process_resource, conversational_run_as_me=True
)

async def test_conversational_run_as_me_defaults_false_when_not_provided(
self, process_resource, mock_uipath_sdk
):
"""conversational_run_as_me defaults to False when omitted (non-conversational agents)."""
process_resource.is_enabled = True
agent = LowCodeAgentDefinition(
input_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
messages=[],
settings=Mock(spec=AgentSettings),
resources=[process_resource],
)
mock_llm = AsyncMock(spec=BaseChatModel)
with patch(
"uipath_langchain.agent.tools.tool_factory.create_process_tool"
) as mock_create_process_tool:
mock_create_process_tool.return_value = MagicMock(
spec=BaseUiPathStructuredTool
)
await create_tools_from_resources(agent, mock_llm)

mock_create_process_tool.assert_called_once_with(
process_resource, conversational_run_as_me=False
)

@pytest.mark.asyncio
@pytest.mark.parametrize(
"resource_fixture",
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading