Skip to content

Commit 49b881d

Browse files
authored
fix(eval): serialize non-json-native tool args when simulating (#1832)
1 parent 59365ec commit 49b881d

5 files changed

Lines changed: 258 additions & 4 deletions

File tree

packages/uipath/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"
3-
version = "2.13.16"
3+
version = "2.13.17"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath/src/uipath/eval/mocks/_llm_mocker.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from opentelemetry.sdk.trace import ReadableSpan
88
from pydantic import BaseModel, TypeAdapter
99

10+
from uipath.core.serialization import serialize_defaults
1011
from uipath.core.tracing import traced
1112
from uipath.platform import UiPath
1213
from uipath.platform.chat import UiPathLlmChatService
@@ -171,7 +172,7 @@ async def response(
171172
"testRunProctorInstructions": self.context.strategy.prompt,
172173
}
173174
prompt_generation_args = {
174-
k: json.dumps(pydantic_to_dict_safe(v))
175+
k: json.dumps(pydantic_to_dict_safe(v), default=serialize_defaults)
175176
for k, v in prompt_input.items()
176177
}
177178
model_parameters = self.context.strategy.model

packages/uipath/src/uipath/eval/mocks/_simulate_component_service.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Service for calling the simulate-component API."""
22

3+
import json
34
from typing import Any
45

56
from uipath._utils import Endpoint
7+
from uipath.core.serialization import serialize_json
68
from uipath.platform.common import BaseService
79
from uipath.platform.constants import (
810
HEADER_INTERNAL_ACCOUNT_ID,
@@ -25,7 +27,7 @@ async def simulate(self, payload: dict[str, Any]) -> dict[str, Any]:
2527
url=Endpoint(
2628
"/agentsruntime_/api/execution/simulations/simulate-component"
2729
),
28-
json=payload,
30+
json=json.loads(serialize_json(payload)),
2931
headers=headers,
3032
)
3133
return response.json()
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
"""Tool arguments must survive simulation regardless of their Python type.
2+
3+
A simulated tool call is serialized twice: once by ``LLMMocker`` to build the
4+
simulation prompt, and once by ``SimulateComponentService`` to POST the payload.
5+
Tool ``args_schema`` models may type a field as any Python type (``uuid.UUID``,
6+
``datetime``, ``Enum``, ...), and LangChain hands the tool the *validated*
7+
value, so both paths receive objects the stdlib JSON encoder rejects.
8+
"""
9+
10+
import json
11+
import uuid
12+
from datetime import datetime, timezone
13+
from enum import Enum
14+
from typing import Any
15+
from unittest.mock import MagicMock, patch
16+
17+
import pytest
18+
from _pytest.monkeypatch import MonkeyPatch
19+
from pytest_httpx import HTTPXMock
20+
21+
from uipath.eval.mocks._mock_runtime import (
22+
clear_execution_context,
23+
set_execution_context,
24+
)
25+
from uipath.eval.mocks._simulate_component_service import (
26+
_create_simulate_component_service,
27+
)
28+
from uipath.eval.mocks._types import (
29+
ComponentSimulationConfig,
30+
LLMMockingStrategy,
31+
MockingContext,
32+
SimulationStrategy,
33+
ToolSimulation,
34+
)
35+
from uipath.eval.mocks.mockable import mockable
36+
37+
_mock_span_collector = MagicMock()
38+
39+
_ATTACHMENT_ID = uuid.UUID("9b702dc7-4988-4fc0-ba81-08deeaade3da")
40+
41+
42+
class _Flavor(str, Enum):
43+
PDF = "pdf"
44+
45+
46+
class _Rank(Enum):
47+
"""A plain Enum: not a str subclass, so json.dumps cannot encode it."""
48+
49+
HIGH = 1
50+
51+
52+
def _llm_context(tool_name: str) -> MockingContext:
53+
return MockingContext(
54+
strategy=LLMMockingStrategy(
55+
prompt="simulate it",
56+
tools_to_simulate=[ToolSimulation(name=tool_name)],
57+
),
58+
name="test-run",
59+
inputs={},
60+
)
61+
62+
63+
class TestLLMMockerArgSerialization:
64+
"""LLMMocker builds its prompt with json.dumps over the raw invocation."""
65+
66+
def teardown_method(self):
67+
clear_execution_context()
68+
69+
@pytest.mark.asyncio
70+
async def test_uuid_argument_does_not_break_prompt_generation(self):
71+
captured: dict[str, Any] = {}
72+
73+
async def _fake_generate(llm, messages, **kwargs):
74+
captured["prompt"] = messages[0]["content"]
75+
return {"ok": True}
76+
77+
@mockable()
78+
async def extraction_tool(**kwargs: Any) -> dict[str, Any]:
79+
raise NotImplementedError("must be simulated, never executed")
80+
81+
set_execution_context(
82+
_llm_context("extraction_tool"), _mock_span_collector, "exec-uuid"
83+
)
84+
with (
85+
patch("uipath.eval.mocks._llm_mocker.UiPath", MagicMock()),
86+
patch("uipath.eval.mocks._llm_mocker.UiPathLlmChatService", MagicMock()),
87+
patch(
88+
"uipath.eval.mocks._llm_mocker.generate_structured_output",
89+
_fake_generate,
90+
),
91+
):
92+
result = await extraction_tool(
93+
id=_ATTACHMENT_ID,
94+
full_name="PO_234.pdf",
95+
mime_type="application/pdf",
96+
)
97+
98+
assert result == {"ok": True}
99+
# The UUID must reach the prompt in its string form.
100+
assert str(_ATTACHMENT_ID) in captured["prompt"]
101+
102+
@pytest.mark.asyncio
103+
async def test_other_non_json_native_arguments_are_serialized(self):
104+
captured: dict[str, Any] = {}
105+
106+
async def _fake_generate(llm, messages, **kwargs):
107+
captured["prompt"] = messages[0]["content"]
108+
return {"ok": True}
109+
110+
@mockable()
111+
async def typed_tool(**kwargs: Any) -> dict[str, Any]:
112+
raise NotImplementedError()
113+
114+
set_execution_context(
115+
_llm_context("typed_tool"), _mock_span_collector, "exec-typed"
116+
)
117+
with (
118+
patch("uipath.eval.mocks._llm_mocker.UiPath", MagicMock()),
119+
patch("uipath.eval.mocks._llm_mocker.UiPathLlmChatService", MagicMock()),
120+
patch(
121+
"uipath.eval.mocks._llm_mocker.generate_structured_output",
122+
_fake_generate,
123+
),
124+
):
125+
await typed_tool(
126+
when=datetime(2026, 7, 27, 12, 39, 59, tzinfo=timezone.utc),
127+
flavor=_Flavor.PDF,
128+
rank=_Rank.HIGH,
129+
tags={"alpha", "beta"},
130+
)
131+
132+
prompt = captured["prompt"]
133+
assert "2026-07-27T12:39:59" in prompt
134+
assert "pdf" in prompt
135+
# A plain (non-str) Enum must be reduced to its value.
136+
assert '"rank": 1' in prompt
137+
# Sets become lists; assert on members so the check stays order-independent.
138+
assert '"alpha"' in prompt
139+
assert '"beta"' in prompt
140+
141+
142+
class TestSimulateComponentPayloadSerialization:
143+
"""The simulate-component payload is encoded by httpx with a bare json.dumps."""
144+
145+
def teardown_method(self):
146+
clear_execution_context()
147+
148+
@pytest.mark.asyncio
149+
@pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
150+
async def test_uuid_in_payload_is_sent_as_string(
151+
self, httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch
152+
):
153+
monkeypatch.setenv("UIPATH_URL", "https://example.com/myorg/mytenant")
154+
monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token")
155+
httpx_mock.add_response(
156+
url="https://example.com/myorg/mytenant/agentsruntime_/api/execution/simulations/simulate-component",
157+
method="POST",
158+
json={"status": 1, "simulatedOutput": "result"},
159+
)
160+
161+
service = _create_simulate_component_service()
162+
result = await service.simulate(
163+
{
164+
"componentId": "extraction_tool",
165+
"input": {
166+
"args": [],
167+
"kwargs": {
168+
"id": _ATTACHMENT_ID,
169+
"when": datetime(2026, 7, 27, 12, 39, 59, tzinfo=timezone.utc),
170+
"rank": _Rank.HIGH,
171+
"tags": {"alpha", "beta"},
172+
},
173+
},
174+
}
175+
)
176+
177+
assert result == {"status": 1, "simulatedOutput": "result"}
178+
sent_kwargs = json.loads(httpx_mock.get_requests()[-1].read())["input"][
179+
"kwargs"
180+
]
181+
assert sent_kwargs["id"] == str(_ATTACHMENT_ID)
182+
assert sent_kwargs["when"].startswith("2026-07-27T12:39:59")
183+
assert sent_kwargs["rank"] == 1
184+
assert sorted(sent_kwargs["tags"]) == ["alpha", "beta"]
185+
186+
@pytest.mark.asyncio
187+
async def test_mocker_end_to_end_with_uuid_argument(self):
188+
captured: list[dict[str, Any]] = []
189+
190+
async def _capture(payload, **kwargs):
191+
captured.append(payload)
192+
return {"status": 1, "simulatedOutput": "ok"}
193+
194+
svc_mock = MagicMock()
195+
svc_mock.simulate = _capture
196+
197+
@mockable()
198+
async def extraction_tool(**kwargs: Any) -> str:
199+
raise NotImplementedError()
200+
201+
context = MockingContext(
202+
strategy=None,
203+
name="test-run",
204+
inputs={},
205+
workload_id="wl-1",
206+
components=[
207+
ComponentSimulationConfig(
208+
component_id="extraction_tool",
209+
simulation_strategy=SimulationStrategy.LLM,
210+
)
211+
],
212+
)
213+
set_execution_context(context, _mock_span_collector, "exec-e2e")
214+
with patch(
215+
"uipath.eval.mocks._simulate_component_mocker._create_simulate_component_service",
216+
return_value=svc_mock,
217+
):
218+
result = await extraction_tool(id=_ATTACHMENT_ID)
219+
220+
assert result == "ok"
221+
assert captured[0]["input"]["kwargs"]["id"] == _ATTACHMENT_ID
222+
223+
224+
class TestSimulateComponentServiceUnchangedBehaviour:
225+
"""Normalization must not alter payloads that already serialized cleanly."""
226+
227+
@pytest.mark.asyncio
228+
@pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
229+
async def test_json_native_payload_is_unchanged(
230+
self, httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch
231+
):
232+
monkeypatch.setenv("UIPATH_URL", "https://example.com/myorg/mytenant")
233+
monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token")
234+
httpx_mock.add_response(
235+
url="https://example.com/myorg/mytenant/agentsruntime_/api/execution/simulations/simulate-component",
236+
method="POST",
237+
json={"status": 1, "simulatedOutput": "result"},
238+
)
239+
240+
payload: dict[str, Any] = {
241+
"componentId": "my_tool",
242+
"componentType": "tool",
243+
"input": {"args": [1, "two", True, None], "kwargs": {"nested": {"a": 1.5}}},
244+
"behaviors": None,
245+
"simulationStrategy": 0,
246+
}
247+
service = _create_simulate_component_service()
248+
await service.simulate(payload)
249+
250+
sent = json.loads(httpx_mock.get_requests()[-1].read())
251+
assert sent == payload

packages/uipath/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)