From 76abdfdd0b7c35a00c9e016871312295bc98e077 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:34:00 -0400 Subject: [PATCH 1/3] fix: remove token sub_type inspection; accept run_as_me from caller The tool factory previously inferred RunAsMe by inspecting the token's sub_type claim (== "user"), which was misclassifying tokens and preventing RunAsMe from propagating to child process / API-workflow tool invocations. create_tools_from_resources now takes an explicit run_as_me: bool parameter. Callers resolve it from UiPathRuntimeContext.conversational_run_as_me (populated from fpsProperties.conversationalService.runAsMe on the harness side). Depends on: - UiPath/uipath-runtime-python#148 (adds the context field) - UiPath/uipath-agents-python (threads the value from context to caller) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agent/tools/tool_factory.py | 29 ++--------- tests/agent/tools/test_tool_factory.py | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/src/uipath_langchain/agent/tools/tool_factory.py b/src/uipath_langchain/agent/tools/tool_factory.py index f6a7fb4b7..801241357 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -31,34 +31,13 @@ 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, + run_as_me: bool = False, ) -> list[BaseTool]: - 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, - ) - - logger.info("Creating tools for agent '%s' from resources", agent.name) + logger.info("Creating tools for agent '%s' (run_as_me=%s)", agent.name, run_as_me) for resource in agent.resources: if not resource.is_enabled: diff --git a/tests/agent/tools/test_tool_factory.py b/tests/agent/tools/test_tool_factory.py index 051d495f6..5c2458f0a 100644 --- a/tests/agent/tools/test_tool_factory.py +++ b/tests/agent/tools/test_tool_factory.py @@ -405,3 +405,53 @@ async def test_function_resource_routes_through_process_tool_path( function_resource, run_as_me=False ) assert tool is not None + + async def test_run_as_me_propagates_to_process_tool( + self, process_resource, mock_uipath_sdk + ): + """run_as_me passed into create_tools_from_resources is forwarded to create_process_tool.""" + 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, run_as_me=True) + + mock_create_process_tool.assert_called_once_with( + process_resource, run_as_me=True + ) + + async def test_run_as_me_defaults_false_when_not_provided( + self, process_resource, mock_uipath_sdk + ): + """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, run_as_me=False + ) From 97efbe45b22369cb433c4d9270a00f6adc8c7fda Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:15:38 -0700 Subject: [PATCH 2/3] fix: omit rpa process from run-as-me propagation --- .../agent/tools/process_tool.py | 7 ++- .../agent/tools/tool_factory.py | 23 +++++++-- tests/agent/tools/test_process_tool.py | 50 +++++++++++++++---- tests/agent/tools/test_tool_factory.py | 26 ++++++---- 4 files changed, 81 insertions(+), 25 deletions(-) diff --git a/src/uipath_langchain/agent/tools/process_tool.py b/src/uipath_langchain/agent/tools/process_tool.py index 962f6d349..991601a82 100644 --- a/src/uipath_langchain/agent/tools/process_tool.py +++ b/src/uipath_langchain/agent/tools/process_tool.py @@ -41,7 +41,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 @@ -54,6 +54,11 @@ def create_process_tool( input_model: Any = create_model(resource.input_schema) output_model: Any = create_model(resource.output_schema) + # 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 801241357..82cb67f6c 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -34,10 +34,18 @@ async def create_tools_from_resources( agent: LowCodeAgentDefinition, llm: BaseChatModel, - run_as_me: bool = False, + 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] = [] - logger.info("Creating tools for agent '%s' (run_as_me=%s)", agent.name, run_as_me) + logger.info( + "Creating tools for agent '%s' (conversational_run_as_me=%s)", + agent.name, + conversational_run_as_me, + ) for resource in agent.resources: if not resource.is_enabled: @@ -54,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): @@ -78,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 5c2458f0a..e5be7611b 100644 --- a/tests/agent/tools/test_tool_factory.py +++ b/tests/agent/tools/test_tool_factory.py @@ -367,7 +367,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( @@ -402,14 +404,18 @@ 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_run_as_me_propagates_to_process_tool( + async def test_conversational_run_as_me_forwarded_to_process_tool( self, process_resource, mock_uipath_sdk ): - """run_as_me passed into create_tools_from_resources is forwarded to create_process_tool.""" + """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": {}}, @@ -425,16 +431,18 @@ async def test_run_as_me_propagates_to_process_tool( mock_create_process_tool.return_value = MagicMock( spec=BaseUiPathStructuredTool ) - await create_tools_from_resources(agent, mock_llm, run_as_me=True) + await create_tools_from_resources( + agent, mock_llm, conversational_run_as_me=True + ) mock_create_process_tool.assert_called_once_with( - process_resource, run_as_me=True + process_resource, conversational_run_as_me=True ) - async def test_run_as_me_defaults_false_when_not_provided( + async def test_conversational_run_as_me_defaults_false_when_not_provided( self, process_resource, mock_uipath_sdk ): - """run_as_me defaults to False when omitted (non-conversational agents).""" + """conversational_run_as_me defaults to False when omitted (non-conversational agents).""" process_resource.is_enabled = True agent = LowCodeAgentDefinition( input_schema={"type": "object", "properties": {}}, @@ -453,5 +461,5 @@ async def test_run_as_me_defaults_false_when_not_provided( await create_tools_from_resources(agent, mock_llm) mock_create_process_tool.assert_called_once_with( - process_resource, run_as_me=False + process_resource, conversational_run_as_me=False ) From 30dc660059ad3cf5c363ed6bef5915f6fb724791 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:18:57 -0700 Subject: [PATCH 3/3] chore: bump version --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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" },