Skip to content

Commit 22f5687

Browse files
mjnoviceclaude
andauthored
fix: classify invalid function inputs as user errors instead of runtime crashes (#1804)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 62081f5 commit 22f5687

8 files changed

Lines changed: 378 additions & 17 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.8"
3+
version = "2.13.9"
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/_cli/_evals/_telemetry.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from uipath.platform.common import UiPathConfig
2222
from uipath.platform.constants import ENV_TENANT_ID
23+
from uipath.runtime.errors import UiPathBaseRuntimeError
2324
from uipath.telemetry._track import is_telemetry_enabled, track_event
2425

2526
logger = logging.getLogger(__name__)
@@ -238,15 +239,15 @@ async def _on_eval_run_updated(self, event: EvalRunUpdatedEvent) -> None:
238239
)
239240

240241
if event.exception_details:
241-
properties["ErrorType"] = type(
242-
event.exception_details.exception
243-
).__name__
244-
properties["ErrorMessage"] = str(event.exception_details.exception)[
245-
:500
246-
]
242+
exception = event.exception_details.exception
243+
properties["ErrorType"] = type(exception).__name__
244+
properties["ErrorMessage"] = str(exception)[:500]
247245
properties["IsRuntimeException"] = (
248246
event.exception_details.runtime_exception
249247
)
248+
if isinstance(exception, UiPathBaseRuntimeError):
249+
properties["ErrorCode"] = exception.error_info.code
250+
properties["ErrorCategory"] = exception.error_info.category.value
250251

251252
self._enrich_properties(properties)
252253

packages/uipath/src/uipath/eval/runtime/runtime.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
UiPathRuntimeStorageProtocol,
3939
)
4040
from uipath.runtime.errors import (
41+
UiPathBaseRuntimeError,
4142
UiPathErrorCategory,
4243
UiPathErrorContract,
4344
)
@@ -96,6 +97,19 @@
9697
logger = logging.getLogger(__name__)
9798

9899

100+
def _is_user_facing_error(exception: BaseException) -> bool:
101+
"""Whether an exception is a correctly-reported workload failure.
102+
103+
User-category errors (e.g. invalid input, business logic failures) are
104+
failures of the agent under evaluation, not eval infrastructure crashes,
105+
so they must not be flagged as runtime exceptions.
106+
"""
107+
return (
108+
isinstance(exception, UiPathBaseRuntimeError)
109+
and exception.error_info.category == UiPathErrorCategory.USER
110+
)
111+
112+
99113
def compute_evaluator_scores(
100114
evaluation_set_results: list[UiPathEvalRunResult],
101115
evaluators: Iterable[GenericBaseEvaluator[Any, Any, Any]],
@@ -768,7 +782,13 @@ async def _execute_eval(
768782
)
769783

770784
except Exception as e:
771-
exception_details = EvalItemExceptionDetails(exception=e)
785+
root_exception: Exception = (
786+
e.root_exception if isinstance(e, EvaluationRuntimeException) else e
787+
)
788+
exception_details = EvalItemExceptionDetails(
789+
exception=root_exception,
790+
runtime_exception=not _is_user_facing_error(root_exception),
791+
)
772792

773793
for evaluator in evaluators:
774794
evaluation_run_results.evaluation_run_results.append(
@@ -793,13 +813,6 @@ async def _execute_eval(
793813
if isinstance(e, EvaluationRuntimeException):
794814
eval_run_updated_event.spans = e.spans
795815
eval_run_updated_event.logs = e.logs
796-
if eval_run_updated_event.exception_details:
797-
eval_run_updated_event.exception_details.exception = (
798-
e.root_exception
799-
)
800-
eval_run_updated_event.exception_details.runtime_exception = (
801-
True
802-
)
803816

804817
await self.event_bus.publish(
805818
EvaluationEvents.UPDATE_EVAL_RUN,

packages/uipath/src/uipath/functions/runtime.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from types import ModuleType
1111
from typing import Any, AsyncGenerator, Callable, Type, cast, get_type_hints
1212

13+
from pydantic import ValidationError
14+
1315
from uipath.runtime import (
1416
UiPathExecuteOptions,
1517
UiPathRuntimeEvent,
@@ -35,6 +37,24 @@
3537
logger = logging.getLogger(__name__)
3638

3739

40+
def _format_input_validation_error(
41+
function_name: str, input_type: Type[Any], error: Exception
42+
) -> str:
43+
"""Build a concise, human-readable message for an input conversion failure."""
44+
type_name = getattr(input_type, "__name__", str(input_type))
45+
if isinstance(error, ValidationError):
46+
issues = "; ".join(
47+
f"{'.'.join(str(loc) for loc in err['loc']) or type_name}: {err['msg']}"
48+
for err in error.errors()
49+
)
50+
else:
51+
issues = str(error)
52+
return (
53+
f"Input does not match the expected schema for "
54+
f"'{function_name}' ({type_name}): {issues}"
55+
)
56+
57+
3858
class UiPathFunctionsRuntime:
3959
"""Runtime wrapper for a single Python function with full script executor compatibility."""
4060

@@ -141,7 +161,18 @@ async def _execute_function(
141161
or is_pydantic_model(input_type)
142162
or (inspect.isclass(input_type) and hasattr(input_type, "__annotations__"))
143163
):
144-
typed_input = convert_to_class(input_data, cast(Type[Any], input_type))
164+
try:
165+
typed_input = convert_to_class(input_data, cast(Type[Any], input_type))
166+
except (ValidationError, TypeError, ValueError) as e:
167+
raise UiPathRuntimeError(
168+
# Closest available code; uipath-runtime 0.12.x has no
169+
# dedicated input-validation error code yet.
170+
UiPathErrorCode.INPUT_INVALID_JSON,
171+
"Invalid input",
172+
_format_input_validation_error(self.function_name, input_type, e),
173+
UiPathErrorCategory.USER,
174+
include_traceback=False,
175+
) from e
145176
result = await func(typed_input) if is_async else func(typed_input)
146177
else:
147178
# Dict/untyped parameter

packages/uipath/tests/cli/eval/test_eval_telemetry.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,45 @@ async def test_on_eval_run_updated_failure(self, mock_track_event):
337337
assert "Test error" in properties["ErrorMessage"]
338338
assert properties["IsRuntimeException"] is True
339339

340+
@pytest.mark.asyncio
341+
@patch("uipath._cli._evals._telemetry.track_event")
342+
async def test_on_eval_run_updated_user_error_includes_classification(
343+
self, mock_track_event
344+
):
345+
"""User-category runtime errors emit ErrorCode/ErrorCategory and are not runtime exceptions."""
346+
from uipath.runtime.errors import (
347+
UiPathErrorCategory,
348+
UiPathErrorCode,
349+
UiPathRuntimeError,
350+
)
351+
352+
subscriber = EvalTelemetrySubscriber()
353+
error = UiPathRuntimeError(
354+
UiPathErrorCode.INPUT_INVALID_JSON,
355+
"Invalid input",
356+
"Input does not match the expected schema",
357+
UiPathErrorCategory.USER,
358+
include_traceback=False,
359+
)
360+
exception_details = EvalItemExceptionDetails(
361+
exception=error,
362+
runtime_exception=False,
363+
)
364+
event = self._create_eval_run_updated_event(
365+
success=False,
366+
exception_details=exception_details,
367+
)
368+
369+
await subscriber._on_eval_run_updated(event)
370+
371+
call_args = mock_track_event.call_args
372+
assert call_args[0][0] == EVAL_RUN_FAILED
373+
properties = call_args[0][1]
374+
assert properties["ErrorType"] == "UiPathRuntimeError"
375+
assert properties["ErrorCode"] == "Python.INPUT_INVALID_JSON"
376+
assert properties["ErrorCategory"] == "User"
377+
assert properties["IsRuntimeException"] is False
378+
340379
@pytest.mark.asyncio
341380
@patch("uipath._cli._evals._telemetry.track_event")
342381
async def test_on_eval_run_updated_with_scores(self, mock_track_event):
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Tests for classifying eval execution errors as user vs runtime failures."""
2+
3+
import uuid
4+
from pathlib import Path
5+
from typing import Any, AsyncGenerator
6+
7+
from uipath.core.events import EventBus
8+
from uipath.core.tracing import UiPathTraceManager
9+
from uipath.eval.helpers import EvalHelpers
10+
from uipath.eval.runtime import UiPathEvalContext, evaluate
11+
from uipath.eval.runtime.events import EvalRunUpdatedEvent, EvaluationEvents
12+
from uipath.eval.runtime.runtime import _is_user_facing_error
13+
from uipath.runtime import (
14+
UiPathExecuteOptions,
15+
UiPathRuntimeEvent,
16+
UiPathRuntimeFactorySettings,
17+
UiPathRuntimeProtocol,
18+
UiPathRuntimeResult,
19+
UiPathRuntimeStorageProtocol,
20+
UiPathStreamOptions,
21+
)
22+
from uipath.runtime.errors import (
23+
UiPathErrorCategory,
24+
UiPathErrorCode,
25+
UiPathRuntimeError,
26+
)
27+
from uipath.runtime.schema import UiPathRuntimeSchema
28+
29+
30+
def _make_error(category: UiPathErrorCategory) -> UiPathRuntimeError:
31+
return UiPathRuntimeError(
32+
UiPathErrorCode.FUNCTION_EXECUTION_ERROR,
33+
"Some failure",
34+
"details",
35+
category,
36+
include_traceback=False,
37+
)
38+
39+
40+
def test_user_category_error_is_user_facing():
41+
assert _is_user_facing_error(_make_error(UiPathErrorCategory.USER)) is True
42+
43+
44+
def test_system_category_error_is_not_user_facing():
45+
assert _is_user_facing_error(_make_error(UiPathErrorCategory.SYSTEM)) is False
46+
47+
48+
def test_unknown_category_error_is_not_user_facing():
49+
assert _is_user_facing_error(_make_error(UiPathErrorCategory.UNKNOWN)) is False
50+
51+
52+
def test_plain_exception_is_not_user_facing():
53+
assert _is_user_facing_error(ValueError("boom")) is False
54+
55+
56+
class _FailingRuntime:
57+
"""Runtime whose execution always raises the configured error."""
58+
59+
def __init__(self, error: Exception):
60+
self._error = error
61+
62+
async def execute(
63+
self,
64+
input: dict[str, Any] | None = None,
65+
options: UiPathExecuteOptions | None = None,
66+
) -> UiPathRuntimeResult:
67+
raise self._error
68+
69+
async def stream(
70+
self,
71+
input: dict[str, Any] | None = None,
72+
options: UiPathStreamOptions | None = None,
73+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
74+
raise self._error
75+
yield # unreachable; makes this an async generator
76+
77+
async def get_schema(self) -> UiPathRuntimeSchema:
78+
return UiPathRuntimeSchema(
79+
filePath="test.py",
80+
uniqueId="test",
81+
type="workflow",
82+
input={"type": "object", "properties": {}},
83+
output={"type": "object", "properties": {}},
84+
)
85+
86+
async def dispose(self) -> None:
87+
pass
88+
89+
90+
class _FailingFactory:
91+
def __init__(self, error: Exception):
92+
self._error = error
93+
94+
def discover_entrypoints(self) -> list[str]:
95+
return ["test"]
96+
97+
async def get_storage(self) -> UiPathRuntimeStorageProtocol | None:
98+
return None
99+
100+
async def get_settings(self) -> UiPathRuntimeFactorySettings | None:
101+
return None
102+
103+
async def new_runtime(
104+
self, entrypoint: str, runtime_id: str, **kwargs
105+
) -> UiPathRuntimeProtocol:
106+
return _FailingRuntime(self._error)
107+
108+
async def dispose(self) -> None:
109+
pass
110+
111+
112+
async def _run_failing_eval(error: Exception) -> list[EvalRunUpdatedEvent]:
113+
"""Run an eval whose execution raises, returning captured run-updated events."""
114+
event_bus = EventBus()
115+
trace_manager = UiPathTraceManager()
116+
captured: list[EvalRunUpdatedEvent] = []
117+
118+
async def capture(event: EvalRunUpdatedEvent) -> None:
119+
captured.append(event)
120+
121+
event_bus.subscribe(EvaluationEvents.UPDATE_EVAL_RUN, capture)
122+
123+
factory = _FailingFactory(error)
124+
eval_set_path = str(Path(__file__).parent / "evals" / "eval-sets" / "default.json")
125+
evaluation_set, _ = EvalHelpers.load_eval_set(eval_set_path)
126+
runtime = await factory.new_runtime("test", "test-runtime-id")
127+
runtime_schema = await runtime.get_schema()
128+
evaluators = await EvalHelpers.load_evaluators(
129+
eval_set_path, evaluation_set, agent_model=None
130+
)
131+
132+
context = UiPathEvalContext()
133+
context.execution_id = str(uuid.uuid4())
134+
context.evaluation_set = evaluation_set
135+
context.runtime_schema = runtime_schema
136+
context.evaluators = evaluators
137+
138+
await evaluate(factory, trace_manager, context, event_bus)
139+
await event_bus.wait_for_all()
140+
return captured
141+
142+
143+
async def test_user_error_execution_is_not_a_runtime_exception():
144+
"""A USER-category failure is reported unwrapped with runtime_exception=False."""
145+
error = UiPathRuntimeError(
146+
UiPathErrorCode.INPUT_INVALID_JSON,
147+
"Invalid input",
148+
"Input does not match the expected schema",
149+
UiPathErrorCategory.USER,
150+
include_traceback=False,
151+
)
152+
events = await _run_failing_eval(error)
153+
154+
failed = [e for e in events if not e.success]
155+
assert failed
156+
details = failed[0].exception_details
157+
assert details is not None
158+
assert details.exception is error
159+
assert details.runtime_exception is False
160+
161+
162+
async def test_unexpected_error_execution_is_a_runtime_exception():
163+
"""A non-user failure in the execution path is flagged as a runtime exception."""
164+
events = await _run_failing_eval(RuntimeError("infrastructure broke"))
165+
166+
failed = [e for e in events if not e.success]
167+
assert failed
168+
details = failed[0].exception_details
169+
assert details is not None
170+
assert isinstance(details.exception, RuntimeError)
171+
assert details.runtime_exception is True

0 commit comments

Comments
 (0)