Skip to content

Commit b8aa0b2

Browse files
dsuresh-apclaude
andauthored
feat: propagate RunAsMe to child process tool jobs (#764)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8aeb635 commit b8aa0b2

5 files changed

Lines changed: 134 additions & 11 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ requires-python = ">=3.11"
77
dependencies = [
88
"uipath>=2.10.49, <2.11.0",
99
"uipath-core>=0.5.2, <0.6.0",
10-
"uipath-platform>=0.1.25, <0.2.0",
10+
"uipath-platform>=0.1.29, <0.2.0",
1111
"uipath-runtime>=0.10.0, <0.11.0",
1212
"langgraph>=1.0.0, <2.0.0",
1313
"langchain-core>=1.2.11, <2.0.0",

src/uipath_langchain/agent/tools/process_tool.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@
3939
}
4040

4141

42-
def create_process_tool(resource: AgentProcessToolResourceConfig) -> StructuredTool:
42+
def create_process_tool(
43+
resource: AgentProcessToolResourceConfig,
44+
run_as_me: bool = False,
45+
) -> StructuredTool:
4346
"""Uses interrupt() to suspend graph execution until process completes (handled by runtime)."""
4447
# Import here to avoid circular dependency
4548
from uipath_langchain.agent.wrappers import get_job_attachment_wrapper
@@ -80,6 +83,7 @@ async def start_job():
8083
attachments=attachments,
8184
parent_span_id=parent_span_id,
8285
parent_operation_id=parent_operation_id,
86+
run_as_me=True if run_as_me else None,
8387
)
8488
except EnrichedException as e:
8589
raise_for_enriched(

src/uipath_langchain/agent/tools/tool_factory.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,32 @@
2929
logger = getLogger(__name__)
3030

3131

32+
def _is_user_token() -> bool:
33+
"""Check if the current token is a user token (sub_type == 'user')."""
34+
try:
35+
from uipath._cli._utils._common import get_claim_from_token
36+
37+
sub_type = get_claim_from_token("sub_type")
38+
logger.info("Token sub_type=%r", sub_type)
39+
return sub_type == "user"
40+
except Exception as e:
41+
logger.info("Token sub_type check failed: %s", e)
42+
return False
43+
44+
3245
async def create_tools_from_resources(
3346
agent: LowCodeAgentDefinition, llm: BaseChatModel
3447
) -> list[BaseTool]:
3548

3649
tools: list[BaseTool] = []
50+
is_user = _is_user_token()
51+
run_as_me = agent.is_conversational and is_user
52+
logger.info(
53+
"RunAsMe decision: is_conversational=%s, is_user_token=%s, run_as_me=%s",
54+
agent.is_conversational,
55+
is_user,
56+
run_as_me,
57+
)
3758

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

@@ -51,7 +72,9 @@ async def create_tools_from_resources(
5172
resource.name,
5273
type(resource).__name__,
5374
)
54-
tool = await _build_tool_for_resource(resource, llm, agent=agent)
75+
tool = await _build_tool_for_resource(
76+
resource, llm, agent=agent, run_as_me=run_as_me
77+
)
5578
if tool is not None:
5679
if isinstance(tool, list):
5780
tools.extend(tool)
@@ -74,9 +97,10 @@ async def _build_tool_for_resource(
7497
resource: BaseAgentResourceConfig,
7598
llm: BaseChatModel,
7699
agent: LowCodeAgentDefinition | None = None,
100+
run_as_me: bool = False,
77101
) -> BaseTool | list[BaseTool] | None:
78102
if isinstance(resource, AgentProcessToolResourceConfig):
79-
return create_process_tool(resource)
103+
return create_process_tool(resource, run_as_me=run_as_me)
80104

81105
elif isinstance(resource, AgentContextResourceConfig):
82106
return create_context_tool(resource, llm=llm, agent=agent)

tests/agent/tools/test_process_tool.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ async def test_invoke_calls_processes_invoke_async(
147147
attachments=[],
148148
parent_span_id=None,
149149
parent_operation_id=None,
150+
run_as_me=None,
150151
)
151152

152153
@pytest.mark.asyncio
@@ -358,3 +359,97 @@ async def test_span_context_defaults_to_none_when_empty(
358359

359360
call_kwargs = mock_client.processes.invoke_async.call_args[1]
360361
assert call_kwargs["parent_span_id"] is None
362+
363+
364+
class TestProcessToolRunAsMe:
365+
"""Test RunAsMe propagation passed top-down from tool factory."""
366+
367+
@pytest.mark.asyncio
368+
@patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt")
369+
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
370+
async def test_run_as_me_true_passed_to_invoke(
371+
self,
372+
mock_uipath_class,
373+
mock_interrupt,
374+
process_resource,
375+
):
376+
"""Test RunAsMe=True is forwarded to invoke_async when set."""
377+
mock_job = MagicMock(spec=Job)
378+
mock_job.key = "job-key"
379+
mock_job.folder_key = "folder-key"
380+
381+
mock_resumed_job = MagicMock(spec=Job)
382+
mock_resumed_job.state = "successful"
383+
384+
mock_client = MagicMock()
385+
mock_client.processes.invoke_async = AsyncMock(return_value=mock_job)
386+
mock_client.jobs.extract_output_async = AsyncMock(return_value=None)
387+
mock_uipath_class.return_value = mock_client
388+
389+
mock_interrupt.return_value = mock_resumed_job
390+
391+
tool = create_process_tool(process_resource, run_as_me=True)
392+
await tool.ainvoke({})
393+
394+
call_kwargs = mock_client.processes.invoke_async.call_args[1]
395+
assert call_kwargs["run_as_me"] is True
396+
397+
@pytest.mark.asyncio
398+
@patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt")
399+
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
400+
async def test_run_as_me_false_sends_none(
401+
self,
402+
mock_uipath_class,
403+
mock_interrupt,
404+
process_resource,
405+
):
406+
"""Test RunAsMe=None when run_as_me=False (default)."""
407+
mock_job = MagicMock(spec=Job)
408+
mock_job.key = "job-key"
409+
mock_job.folder_key = "folder-key"
410+
411+
mock_resumed_job = MagicMock(spec=Job)
412+
mock_resumed_job.state = "successful"
413+
414+
mock_client = MagicMock()
415+
mock_client.processes.invoke_async = AsyncMock(return_value=mock_job)
416+
mock_client.jobs.extract_output_async = AsyncMock(return_value=None)
417+
mock_uipath_class.return_value = mock_client
418+
419+
mock_interrupt.return_value = mock_resumed_job
420+
421+
tool = create_process_tool(process_resource, run_as_me=False)
422+
await tool.ainvoke({})
423+
424+
call_kwargs = mock_client.processes.invoke_async.call_args[1]
425+
assert call_kwargs["run_as_me"] is None
426+
427+
@pytest.mark.asyncio
428+
@patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt")
429+
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
430+
async def test_run_as_me_default_sends_none(
431+
self,
432+
mock_uipath_class,
433+
mock_interrupt,
434+
process_resource,
435+
):
436+
"""Test RunAsMe=None when run_as_me not specified (default)."""
437+
mock_job = MagicMock(spec=Job)
438+
mock_job.key = "job-key"
439+
mock_job.folder_key = "folder-key"
440+
441+
mock_resumed_job = MagicMock(spec=Job)
442+
mock_resumed_job.state = "successful"
443+
444+
mock_client = MagicMock()
445+
mock_client.processes.invoke_async = AsyncMock(return_value=mock_job)
446+
mock_client.jobs.extract_output_async = AsyncMock(return_value=None)
447+
mock_uipath_class.return_value = mock_client
448+
449+
mock_interrupt.return_value = mock_resumed_job
450+
451+
tool = create_process_tool(process_resource)
452+
await tool.ainvoke({})
453+
454+
call_kwargs = mock_client.processes.invoke_async.call_args[1]
455+
assert call_kwargs["run_as_me"] is None

uv.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)