diff --git a/pyproject.toml b/pyproject.toml index 92290ce6f..ac43b3b36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/uipath_langchain/agent/tools/process_tool.py b/src/uipath_langchain/agent/tools/process_tool.py index 52b158ad2..9b0c8ce31 100644 --- a/src/uipath_langchain/agent/tools/process_tool.py +++ b/src/uipath_langchain/agent/tools/process_tool.py @@ -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 @@ -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] = {} diff --git a/src/uipath_langchain/agent/tools/tool_factory.py b/src/uipath_langchain/agent/tools/tool_factory.py index f6a7fb4b7..82cb67f6c 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -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( @@ -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): @@ -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) diff --git a/tests/agent/tools/test_process_tool.py b/tests/agent/tools/test_process_tool.py index 4da3d92c2..1f9d52499 100644 --- a/tests/agent/tools/test_process_tool.py +++ b/tests/agent/tools/test_process_tool.py @@ -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" @@ -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] @@ -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" @@ -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] @@ -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" diff --git a/tests/agent/tools/test_tool_factory.py b/tests/agent/tools/test_tool_factory.py index 333f731d8..b07724dfb 100644 --- a/tests/agent/tools/test_tool_factory.py +++ b/tests/agent/tools/test_tool_factory.py @@ -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( @@ -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", diff --git a/uv.lock b/uv.lock index b1a4878fc..a90039156 100644 --- a/uv.lock +++ b/uv.lock @@ -4498,7 +4498,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.14.15" +version = "0.14.16" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },