Skip to content

Commit 156906b

Browse files
feat: process tools tracing and interrupt (#546)
1 parent 19d5957 commit 156906b

4 files changed

Lines changed: 299 additions & 36 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.5.39"
3+
version = "0.5.40"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/agent/tools/process_tool.py

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
from langchain.tools import BaseTool
66
from langchain_core.messages import ToolCall
77
from langchain_core.tools import StructuredTool
8+
from langgraph.func import task
89
from langgraph.types import interrupt
910
from uipath.agent.models.agent import AgentProcessToolResourceConfig
1011
from uipath.eval.mocks import mockable
11-
from uipath.platform.common import InvokeProcess
12+
from uipath.platform import UiPath
13+
from uipath.platform.common import WaitJob
1214

1315
from uipath_langchain.agent.react.job_attachments import get_job_attachments
1416
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
@@ -36,25 +38,37 @@ def create_process_tool(resource: AgentProcessToolResourceConfig) -> StructuredT
3638
input_model: Any = create_model(resource.input_schema)
3739
output_model: Any = create_model(resource.output_schema)
3840

39-
@mockable(
40-
name=resource.name,
41-
description=resource.description,
42-
input_schema=input_model.model_json_schema(),
43-
output_schema=output_model.model_json_schema(),
44-
example_calls=resource.properties.example_calls,
45-
)
41+
_span_context: dict[str, Any] = {}
42+
4643
async def process_tool_fn(**kwargs: Any):
4744
attachments = get_job_attachments(input_model, kwargs)
4845
input_arguments = input_model.model_validate(kwargs).model_dump(mode="json")
49-
return interrupt(
50-
InvokeProcess(
51-
name=process_name,
52-
input_arguments=input_arguments,
53-
process_folder_path=folder_path,
54-
process_folder_key=None,
55-
attachments=attachments,
56-
)
46+
47+
@mockable(
48+
name=tool_name.lower(),
49+
description=resource.description,
50+
input_schema=input_model.model_json_schema(),
51+
output_schema=output_model.model_json_schema(),
52+
example_calls=resource.properties.example_calls,
5753
)
54+
async def invoke_process():
55+
parent_span_id = _span_context.pop("parent_span_id", None)
56+
57+
@task
58+
async def start_job():
59+
client = UiPath()
60+
return await client.processes.invoke_async(
61+
name=process_name,
62+
input_arguments=input_arguments,
63+
folder_path=folder_path,
64+
attachments=attachments,
65+
parent_span_id=parent_span_id,
66+
)
67+
68+
job = await start_job()
69+
return interrupt(WaitJob(job=job, process_folder_key=job.folder_key))
70+
71+
return await invoke_process()
5872

5973
job_attachment_wrapper = get_job_attachment_wrapper(output_type=output_model)
6074

@@ -73,11 +87,12 @@ async def process_tool_wrapper(
7387
coroutine=process_tool_fn,
7488
output_type=output_model,
7589
metadata={
76-
"tool_type": "process",
90+
"tool_type": resource.type.lower(),
7791
"display_name": process_name,
7892
"folder_path": folder_path,
7993
"args_schema": input_model,
8094
"output_schema": output_model,
95+
"_span_context": _span_context,
8196
},
8297
argument_properties=resource.argument_properties,
8398
)
Lines changed: 265 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,77 @@
1-
"""Tests for process_tool.py metadata."""
1+
"""Tests for process_tool.py."""
2+
3+
from unittest.mock import AsyncMock, MagicMock, patch
24

35
import pytest
46
from uipath.agent.models.agent import (
57
AgentProcessToolProperties,
68
AgentProcessToolResourceConfig,
79
AgentToolType,
810
)
11+
from uipath.platform.common import WaitJob
12+
from uipath.platform.orchestrator import Job
913

1014
from uipath_langchain.agent.tools.process_tool import create_process_tool
1115

1216

17+
def _noop_task(fn):
18+
"""No-op replacement for @task so it works outside Pregel context."""
19+
return fn
20+
21+
22+
@pytest.fixture(autouse=True)
23+
def _patch_lg_task():
24+
"""Patch @task decorator to no-op since unit tests run outside Pregel context."""
25+
with patch("uipath_langchain.agent.tools.process_tool.task", _noop_task):
26+
yield
27+
28+
29+
@pytest.fixture
30+
def process_resource():
31+
"""Create a minimal process tool resource config."""
32+
return AgentProcessToolResourceConfig(
33+
type=AgentToolType.PROCESS,
34+
name="test_process",
35+
description="Test process description",
36+
input_schema={"type": "object", "properties": {}},
37+
output_schema={"type": "object", "properties": {}},
38+
properties=AgentProcessToolProperties(
39+
process_name="MyProcess",
40+
folder_path="/Shared/MyFolder",
41+
),
42+
)
43+
44+
45+
@pytest.fixture
46+
def process_resource_with_inputs():
47+
"""Create a process tool resource config with input arguments."""
48+
return AgentProcessToolResourceConfig(
49+
type=AgentToolType.PROCESS,
50+
name="data_processor",
51+
description="Process data with arguments",
52+
input_schema={
53+
"type": "object",
54+
"properties": {
55+
"name": {"type": "string"},
56+
"count": {"type": "integer"},
57+
},
58+
},
59+
output_schema={
60+
"type": "object",
61+
"properties": {
62+
"result": {"type": "string"},
63+
},
64+
},
65+
properties=AgentProcessToolProperties(
66+
process_name="DataProcessor",
67+
folder_path="/Shared/DataFolder",
68+
),
69+
)
70+
71+
1372
class TestProcessToolMetadata:
1473
"""Test that process tool has correct metadata for observability."""
1574

16-
@pytest.fixture
17-
def process_resource(self):
18-
"""Create a minimal process tool resource config."""
19-
return AgentProcessToolResourceConfig(
20-
type=AgentToolType.PROCESS,
21-
name="test_process",
22-
description="Test process description",
23-
input_schema={"type": "object", "properties": {}},
24-
output_schema={"type": "object", "properties": {}},
25-
properties=AgentProcessToolProperties(
26-
process_name="MyProcess",
27-
folder_path="/Shared/MyFolder",
28-
),
29-
)
30-
3175
def test_process_tool_has_metadata(self, process_resource):
3276
"""Test that process tool has metadata dict."""
3377
tool = create_process_tool(process_resource)
@@ -36,7 +80,7 @@ def test_process_tool_has_metadata(self, process_resource):
3680
assert isinstance(tool.metadata, dict)
3781

3882
def test_process_tool_metadata_has_tool_type(self, process_resource):
39-
"""Test that metadata contains tool_type for span detection."""
83+
"""Test that metadata contains tool_type derived from resource type."""
4084
tool = create_process_tool(process_resource)
4185
assert tool.metadata is not None
4286
assert tool.metadata["tool_type"] == "process"
@@ -52,3 +96,207 @@ def test_process_tool_metadata_has_folder_path(self, process_resource):
5296
tool = create_process_tool(process_resource)
5397
assert tool.metadata is not None
5498
assert tool.metadata["folder_path"] == "/Shared/MyFolder"
99+
100+
def test_process_tool_metadata_has_span_context(self, process_resource):
101+
"""Test that metadata contains _span_context dict for tracing."""
102+
tool = create_process_tool(process_resource)
103+
assert tool.metadata is not None
104+
assert "_span_context" in tool.metadata
105+
assert isinstance(tool.metadata["_span_context"], dict)
106+
107+
def test_process_tool_metadata_tool_type_uses_resource_type(self):
108+
"""Test that tool_type is derived from resource.type.lower()."""
109+
resource = AgentProcessToolResourceConfig(
110+
type=AgentToolType.PROCESS,
111+
name="test_process",
112+
description="Test",
113+
input_schema={"type": "object", "properties": {}},
114+
output_schema={"type": "object", "properties": {}},
115+
properties=AgentProcessToolProperties(
116+
process_name="MyProcess",
117+
folder_path="/Shared",
118+
),
119+
)
120+
tool = create_process_tool(resource)
121+
assert tool.metadata is not None
122+
assert tool.metadata["tool_type"] == resource.type.lower()
123+
124+
125+
class TestProcessToolInvocation:
126+
"""Test process tool invocation behavior: invoke then interrupt."""
127+
128+
@pytest.mark.asyncio
129+
@patch("uipath_langchain.agent.tools.process_tool.interrupt")
130+
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
131+
async def test_invoke_calls_processes_invoke_async(
132+
self, mock_uipath_class, mock_interrupt, process_resource
133+
):
134+
"""Test that invoking the tool calls client.processes.invoke_async."""
135+
mock_job = MagicMock(spec=Job)
136+
mock_job.folder_key = "folder-key-123"
137+
138+
mock_client = MagicMock()
139+
mock_client.processes.invoke_async = AsyncMock(return_value=mock_job)
140+
mock_uipath_class.return_value = mock_client
141+
142+
mock_interrupt.return_value = {"output": "result"}
143+
144+
tool = create_process_tool(process_resource)
145+
await tool.ainvoke({})
146+
147+
mock_client.processes.invoke_async.assert_called_once_with(
148+
name="MyProcess",
149+
input_arguments={},
150+
folder_path="/Shared/MyFolder",
151+
attachments=[],
152+
parent_span_id=None,
153+
)
154+
155+
@pytest.mark.asyncio
156+
@patch("uipath_langchain.agent.tools.process_tool.interrupt")
157+
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
158+
async def test_invoke_interrupts_with_wait_job(
159+
self, mock_uipath_class, mock_interrupt, process_resource
160+
):
161+
"""Test that after invoking, the tool interrupts with WaitJob."""
162+
mock_job = MagicMock(spec=Job)
163+
mock_job.folder_key = "folder-key-456"
164+
165+
mock_client = MagicMock()
166+
mock_client.processes.invoke_async = AsyncMock(return_value=mock_job)
167+
mock_uipath_class.return_value = mock_client
168+
169+
mock_interrupt.return_value = {"output": "done"}
170+
171+
tool = create_process_tool(process_resource)
172+
await tool.ainvoke({})
173+
174+
mock_interrupt.assert_called_once()
175+
wait_job_arg = mock_interrupt.call_args[0][0]
176+
assert isinstance(wait_job_arg, WaitJob)
177+
assert wait_job_arg.job == mock_job
178+
assert wait_job_arg.process_folder_key == "folder-key-456"
179+
180+
@pytest.mark.asyncio
181+
@patch("uipath_langchain.agent.tools.process_tool.interrupt")
182+
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
183+
async def test_invoke_passes_input_arguments(
184+
self, mock_uipath_class, mock_interrupt, process_resource_with_inputs
185+
):
186+
"""Test that input arguments are correctly passed to invoke_async."""
187+
mock_job = MagicMock(spec=Job)
188+
mock_job.folder_key = "folder-key"
189+
190+
mock_client = MagicMock()
191+
mock_client.processes.invoke_async = AsyncMock(return_value=mock_job)
192+
mock_uipath_class.return_value = mock_client
193+
194+
mock_interrupt.return_value = {"result": "processed"}
195+
196+
tool = create_process_tool(process_resource_with_inputs)
197+
await tool.ainvoke({"name": "test-data", "count": 42})
198+
199+
call_kwargs = mock_client.processes.invoke_async.call_args[1]
200+
assert call_kwargs["input_arguments"] == {"name": "test-data", "count": 42}
201+
assert call_kwargs["name"] == "DataProcessor"
202+
assert call_kwargs["folder_path"] == "/Shared/DataFolder"
203+
204+
@pytest.mark.asyncio
205+
@patch("uipath_langchain.agent.tools.process_tool.interrupt")
206+
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
207+
async def test_invoke_returns_interrupt_value(
208+
self, mock_uipath_class, mock_interrupt, process_resource
209+
):
210+
"""Test that the tool returns the value from interrupt()."""
211+
mock_job = MagicMock(spec=Job)
212+
mock_job.folder_key = "folder-key"
213+
214+
mock_client = MagicMock()
215+
mock_client.processes.invoke_async = AsyncMock(return_value=mock_job)
216+
mock_uipath_class.return_value = mock_client
217+
218+
mock_interrupt.return_value = {"output_arg": "value123"}
219+
220+
tool = create_process_tool(process_resource)
221+
result = await tool.ainvoke({})
222+
223+
assert result == {"output_arg": "value123"}
224+
225+
226+
class TestProcessToolSpanContext:
227+
"""Test that _span_context is properly wired for tracing."""
228+
229+
@pytest.mark.asyncio
230+
@patch("uipath_langchain.agent.tools.process_tool.interrupt")
231+
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
232+
async def test_span_context_parent_span_id_passed_to_invoke(
233+
self, mock_uipath_class, mock_interrupt, process_resource
234+
):
235+
"""Test that parent_span_id from _span_context is forwarded to invoke_async."""
236+
mock_job = MagicMock(spec=Job)
237+
mock_job.folder_key = "folder-key"
238+
239+
mock_client = MagicMock()
240+
mock_client.processes.invoke_async = AsyncMock(return_value=mock_job)
241+
mock_uipath_class.return_value = mock_client
242+
243+
mock_interrupt.return_value = {}
244+
245+
tool = create_process_tool(process_resource)
246+
assert tool.metadata is not None
247+
248+
# Simulate tracing setting parent_span_id via the shared _span_context
249+
tool.metadata["_span_context"]["parent_span_id"] = "span-abc-123"
250+
251+
await tool.ainvoke({})
252+
253+
call_kwargs = mock_client.processes.invoke_async.call_args[1]
254+
assert call_kwargs["parent_span_id"] == "span-abc-123"
255+
256+
@pytest.mark.asyncio
257+
@patch("uipath_langchain.agent.tools.process_tool.interrupt")
258+
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
259+
async def test_span_context_consumed_after_invoke(
260+
self, mock_uipath_class, mock_interrupt, process_resource
261+
):
262+
"""Test that parent_span_id is popped (consumed) from _span_context after use."""
263+
mock_job = MagicMock(spec=Job)
264+
mock_job.folder_key = "folder-key"
265+
266+
mock_client = MagicMock()
267+
mock_client.processes.invoke_async = AsyncMock(return_value=mock_job)
268+
mock_uipath_class.return_value = mock_client
269+
270+
mock_interrupt.return_value = {}
271+
272+
tool = create_process_tool(process_resource)
273+
assert tool.metadata is not None
274+
tool.metadata["_span_context"]["parent_span_id"] = "span-xyz"
275+
276+
await tool.ainvoke({})
277+
278+
# parent_span_id should be consumed (popped) after invocation
279+
assert "parent_span_id" not in tool.metadata["_span_context"]
280+
281+
@pytest.mark.asyncio
282+
@patch("uipath_langchain.agent.tools.process_tool.interrupt")
283+
@patch("uipath_langchain.agent.tools.process_tool.UiPath")
284+
async def test_span_context_defaults_to_none_when_empty(
285+
self, mock_uipath_class, mock_interrupt, process_resource
286+
):
287+
"""Test that parent_span_id defaults to None when _span_context is empty."""
288+
mock_job = MagicMock(spec=Job)
289+
mock_job.folder_key = "folder-key"
290+
291+
mock_client = MagicMock()
292+
mock_client.processes.invoke_async = AsyncMock(return_value=mock_job)
293+
mock_uipath_class.return_value = mock_client
294+
295+
mock_interrupt.return_value = {}
296+
297+
tool = create_process_tool(process_resource)
298+
# Don't set any parent_span_id
299+
await tool.ainvoke({})
300+
301+
call_kwargs = mock_client.processes.invoke_async.call_args[1]
302+
assert call_kwargs["parent_span_id"] is None

0 commit comments

Comments
 (0)