diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index b495babf6..99b1a6f09 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.8" +version = "0.5.9" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/guardrails/_evaluators.py b/packages/uipath-core/src/uipath/core/guardrails/_evaluators.py index ff935fcd1..51c90413c 100644 --- a/packages/uipath-core/src/uipath/core/guardrails/_evaluators.py +++ b/packages/uipath-core/src/uipath/core/guardrails/_evaluators.py @@ -68,6 +68,26 @@ def _traverse(current: Any, remaining_parts: list[str]) -> None: field_name, array_depth = _parse_path_segment(part) if isinstance(current, dict): + if not field_name and array_depth != ArrayDepth.NONE: + # Path segment is purely array notation (e.g. [*]) with no + # field name. This happens when the root data is a + # dict-wrapped array (e.g. {"output": [...]}) and the + # original path starts with [*]. Iterate over all list + # values in the dict so the array elements are reached. + for value in current.values(): + if isinstance(value, list): + if array_depth == ArrayDepth.MATRIX: + for row in value: + if isinstance(row, list): + for item in row: + _traverse(item, next_parts) + else: + _traverse(row, next_parts) + else: # SINGLE + for item in value: + _traverse(item, next_parts) + return + if field_name not in current: return next_value = current.get(field_name) diff --git a/packages/uipath-core/tests/guardrails/test_deterministic_guardrails_service.py b/packages/uipath-core/tests/guardrails/test_deterministic_guardrails_service.py index 0bafde94f..76f0d6233 100644 --- a/packages/uipath-core/tests/guardrails/test_deterministic_guardrails_service.py +++ b/packages/uipath-core/tests/guardrails/test_deterministic_guardrails_service.py @@ -1567,3 +1567,98 @@ def test_boolean_rule_missing_field_passes( guardrail=guardrail, ) assert result.result == GuardrailValidationResultType.PASSED + + +class TestWrappedArrayOutputEvaluation: + """Test that guardrails work when tool output is a dict-wrapped array. + + When a tool returns a JSON array, _extract_tool_output_data wraps it as + {"output": [...]}. Field paths starting with [*] (e.g. [*].author.email) + must still resolve correctly against the wrapped structure. + """ + + def test_word_rule_on_wrapped_array_output_detects_violation( + self, service: DeterministicGuardrailsService + ) -> None: + guardrail = DeterministicGuardrail( + id="test-wrapped-array", + name="Wrapped Array Guardrail", + description="Test wrapped array output", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + WordRule( + rule_type="word", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference( + path="[*].author.emailAddress", + source=FieldSource.OUTPUT, + ) + ], + ), + detects_violation=lambda s: "iana" in s, + rule_description="email contains 'iana'", + ), + ], + ) + # Simulates output from _extract_tool_output_data when tool returns an array. + # All emails contain "iana" so every field violates the rule → guardrail fails. + output_data = { + "output": [ + {"author": {"emailAddress": "briana.smith@test.com"}, "body": "comment1"}, + {"author": {"emailAddress": "adriana.jones@test.com"}, "body": "comment2"}, + ] + } + result = service.evaluate_post_deterministic_guardrail( + input_data={}, + output_data=output_data, + guardrail=guardrail, + ) + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + + def test_word_rule_on_wrapped_array_output_passes_when_no_match( + self, service: DeterministicGuardrailsService + ) -> None: + guardrail = DeterministicGuardrail( + id="test-wrapped-array", + name="Wrapped Array Guardrail", + description="Test wrapped array output", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + WordRule( + rule_type="word", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference( + path="[*].author.emailAddress", + source=FieldSource.OUTPUT, + ) + ], + ), + detects_violation=lambda s: "iana" in s, + rule_description="email contains 'iana'", + ), + ], + ) + output_data = { + "output": [ + {"author": {"emailAddress": "mike.wilson@test.com"}, "body": "comment1"}, + {"author": {"emailAddress": "sarah.clark@test.com"}, "body": "comment2"}, + ] + } + result = service.evaluate_post_deterministic_guardrail( + input_data={}, + output_data=output_data, + guardrail=guardrail, + ) + assert result.result == GuardrailValidationResultType.PASSED diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index f90fa8d2e..97bfa5ff1 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1007,7 +1007,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.8" +version = "0.5.9" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 1542a2fb2..a46dc4c6b 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1056,7 +1056,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.8" +version = "0.5.9" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" },