Skip to content

Commit ef97d78

Browse files
committed
feat(guardrails_servie): complete guardrail
1 parent 85056ab commit ef97d78

File tree

8 files changed

+1602
-458
lines changed

8 files changed

+1602
-458
lines changed

src/uipath/_cli/_runtime/_hitl.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,24 @@ async def read(cls, resume_trigger: UiPathResumeTrigger) -> Optional[str]:
6262
default_escalation = Escalation()
6363
match resume_trigger.trigger_type:
6464
case UiPathResumeTriggerType.ACTION:
65+
include_metadata = False
66+
if resume_trigger.payload:
67+
try:
68+
payload_data = json.loads(resume_trigger.payload)
69+
include_metadata = payload_data.get("include_metadata", False)
70+
except (json.JSONDecodeError, AttributeError):
71+
include_metadata = False
72+
6573
if resume_trigger.item_key:
6674
action = await uipath.actions.retrieve_async(
6775
resume_trigger.item_key,
6876
app_folder_key=resume_trigger.folder_key,
6977
app_folder_path=resume_trigger.folder_path,
7078
)
7179

80+
if include_metadata:
81+
return action
82+
7283
if default_escalation.enabled:
7384
return default_escalation.extract_response_value(action.data)
7485

src/uipath/_services/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .entities_service import EntitiesService
1010
from .external_application_service import ExternalApplicationService
1111
from .folder_service import FolderService
12+
from .guardrails_service import GuardrailsService
1213
from .jobs_service import JobsService
1314
from .llm_gateway_service import UiPathLlmChatService, UiPathOpenAIService
1415
from .processes_service import ProcessesService
@@ -31,4 +32,5 @@
3132
"FolderService",
3233
"EntitiesService",
3334
"ExternalApplicationService",
35+
"GuardrailsService",
3436
]
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
from enum import Enum
2+
from typing import Any, Dict, List, Optional, Union
3+
4+
from uipath._cli._runtime._hitl import HitlProcessor, HitlReader
5+
6+
from .._config import Config
7+
from .._execution_context import ExecutionContext
8+
from .._folder_context import FolderContext
9+
from .._utils import Endpoint, RequestSpec, header_folder
10+
from ..models import CreateAction
11+
from ..models.guardrails import (
12+
BlockAction,
13+
BuiltInValidatorGuardrail,
14+
EscalateAction,
15+
FilterAction,
16+
Guardrail,
17+
LogAction,
18+
)
19+
from ..tracing import traced
20+
from ._base_service import BaseService
21+
22+
23+
class GuardrailResult(Enum):
24+
APPROVE = "approve"
25+
REJECT = "reject"
26+
27+
28+
class GuardrailViolationError(Exception):
29+
"""Exception raised when guardrail validation fails."""
30+
31+
def __init__(self, detected_issue: Any):
32+
self.detected_issue = detected_issue
33+
super().__init__(f"Guardrail violation detected: {detected_issue}")
34+
35+
36+
class GuardrailsService(FolderContext, BaseService):
37+
"""Service for validating text against UiPath Guardrails."""
38+
39+
def __init__(self, config: Config, execution_context: ExecutionContext) -> None:
40+
super().__init__(config=config, execution_context=execution_context)
41+
42+
@traced
43+
def evaluate_guardrail(
44+
self,
45+
input_data: Union[str, Dict[str, Any]],
46+
guardrail: Guardrail,
47+
*,
48+
folder_key: Optional[str] = None,
49+
folder_path: Optional[str] = None,
50+
) -> Dict[str, Any]:
51+
"""
52+
Call the API to validate input_data with the given guardrail.
53+
Only supports built-in guardrails for now.
54+
"""
55+
if isinstance(guardrail, BuiltInValidatorGuardrail):
56+
parameters = [
57+
param.model_dump(by_alias=True)
58+
for param in guardrail.validator_parameters
59+
]
60+
payload = {
61+
"validator": guardrail.validator_type,
62+
"input": input_data if isinstance(input_data, str) else str(input_data),
63+
"parameters": parameters,
64+
}
65+
spec = RequestSpec(
66+
method="POST",
67+
endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"),
68+
json=payload,
69+
headers={**header_folder(folder_key, folder_path)},
70+
)
71+
response = self.request(
72+
spec.method,
73+
url=spec.endpoint,
74+
json=spec.json,
75+
headers=spec.headers,
76+
)
77+
return response.json()
78+
else:
79+
raise NotImplementedError(
80+
"Custom guardrail validation is not yet supported by the API."
81+
)
82+
83+
@traced
84+
async def execute_guardrail(
85+
self,
86+
validation_result: Dict[str, Any],
87+
guardrail: Guardrail,
88+
tool_name: str,
89+
) -> None:
90+
"""
91+
Execute the action specified by the guardrail if validation failed.
92+
Raise, log, escalate, or print, depending on action type.
93+
"""
94+
if validation_result.get("validation_passed", True):
95+
return
96+
97+
action = guardrail.action
98+
99+
if isinstance(action, EscalateAction):
100+
action_data = {
101+
"GuardrailName": guardrail.name,
102+
"GuardrailDescription": validation_result.get(
103+
"reason", "No description provided"
104+
),
105+
# TODO must see where to i extract these
106+
# "TenantName": self.config.tenant_name,
107+
# "AgentTrace": must see,
108+
"Tool": tool_name,
109+
# "ExecutionStage": validation_result.get("execution_stage", ""),
110+
# "ToolInputs": ,
111+
# "ToolOutputs": validation_result.get("tool_outputs", {}),
112+
}
113+
# mandatory: app_name + tittle + data + app_version + assignee (def none) + appfolderpath + includemetadata = true
114+
create_action = CreateAction(
115+
title="Guardrail Escalation: " + guardrail.name,
116+
data=action_data,
117+
assignee=action.recipient.value,
118+
app_name=action.app.name,
119+
app_folder_path=action.app.folder_name,
120+
app_folder_key=action.app.folder_id,
121+
app_key=action.app.id,
122+
app_version=action.app.version,
123+
include_metadata=True,
124+
)
125+
126+
# nu merge asta
127+
# action_output = interrupt(create_action)
128+
processor = HitlProcessor(create_action)
129+
resume_trigger = await processor.create_resume_trigger()
130+
action_output = await HitlReader.read(resume_trigger)
131+
132+
if hasattr(action_output, "action"):
133+
if action_output.action == "Approve":
134+
if hasattr(action_output, "data") and action_output.data.get(
135+
"ReviewedInputs"
136+
):
137+
# Re-evaluate with reviewed inputs
138+
await self.evaluate_guardrail(
139+
input_data=action_output.data["ReviewedInputs"],
140+
guardrail=guardrail,
141+
folder_key=action.app.folder_id,
142+
folder_path=action.app.folder_name,
143+
)
144+
return
145+
elif action_output.action == "Reject":
146+
reason = "Guardrail violation rejected by user"
147+
if hasattr(action_output, "data") and action_output.data:
148+
reason = action_output.data.get("Reason", reason)
149+
raise GuardrailViolationError(reason)
150+
151+
elif isinstance(action, BlockAction):
152+
raise GuardrailViolationError(action.reason)
153+
elif isinstance(action, LogAction):
154+
reason = validation_result.get("reason", "Guardrail violation detected")
155+
severity = action.severity_level.value
156+
print(f"GUARDRAIL LOG [{severity}]: {reason}")
157+
158+
elif isinstance(action, FilterAction):
159+
# TODO: see what it clearly does
160+
# implement filtering logic
161+
print(
162+
f"GUARDRAIL FILTER: Fields to filter: {[f.path for f in action.fields]}"
163+
)
164+
165+
def _should_apply_guardrail(self, guardrail: Guardrail, tool_name: str) -> bool:
166+
"""Check if guardrail should apply to the current tool context."""
167+
selector = guardrail.selector
168+
169+
# Check scopes
170+
scope_values = [scope.value for scope in selector.scopes]
171+
if "Tool" not in scope_values:
172+
return False
173+
174+
# Check match names (if specified)
175+
if selector.match_names:
176+
return tool_name in selector.match_names or "*" in selector.match_names
177+
178+
return True
179+
180+
@traced
181+
async def process_guardrails(
182+
self,
183+
input_data: Union[str, Dict[str, Any]],
184+
guardrails: List[Guardrail],
185+
tool_name: str = "unknown",
186+
*,
187+
folder_key: Optional[str] = None,
188+
folder_path: Optional[str] = None,
189+
stop_on_first_failure: bool = True,
190+
) -> Dict[str, Any]:
191+
"""
192+
Process multiple guardrails: evaluate each one and execute its action if needed.
193+
194+
Args:
195+
input_data: Data to validate
196+
guardrails: List of guardrails to process
197+
tool_name: Name of the tool being validated
198+
folder_key: Optional folder key
199+
folder_path: Optional folder path
200+
stop_on_first_failure: Whether to stop processing on first failure
201+
202+
Returns:
203+
Summary of processing results
204+
"""
205+
results = {
206+
"input_data": str(input_data),
207+
"tool_name": tool_name,
208+
"total_guardrails": len(guardrails),
209+
"processed": 0,
210+
"passed": 0,
211+
"failed": 0,
212+
"skipped": 0,
213+
"errors": 0,
214+
"all_passed": True,
215+
"details": [],
216+
}
217+
218+
print(f"Processing {len(guardrails)} guardrail(s) for tool '{tool_name}'")
219+
220+
for i, guardrail in enumerate(guardrails):
221+
detail = {
222+
"guardrail_id": guardrail.id,
223+
"guardrail_name": guardrail.name,
224+
"guardrail_type": guardrail.guardrail_type,
225+
"index": i + 1,
226+
}
227+
228+
try:
229+
print(f"[{i + 1}/{len(guardrails)}] {guardrail.name}")
230+
231+
# Check if guardrail applies to this context
232+
if not self._should_apply_guardrail(guardrail, tool_name):
233+
results["skipped"] += 1
234+
detail.update(
235+
{"status": "skipped", "reason": "Scope or name mismatch"}
236+
)
237+
results["details"].append(detail)
238+
continue
239+
240+
# 1: Evaluate guardrail
241+
validation_result = self.evaluate_guardrail(
242+
input_data=input_data,
243+
guardrail=guardrail,
244+
folder_key=folder_key,
245+
folder_path=folder_path,
246+
)
247+
248+
results["processed"] += 1
249+
validation_passed = validation_result.get("validation_passed", True)
250+
251+
if validation_passed:
252+
results["passed"] += 1
253+
detail.update(
254+
{"status": "passed", "validation_result": validation_result}
255+
)
256+
else:
257+
results["failed"] += 1
258+
results["all_passed"] = False
259+
reason = validation_result.get("reason", "Unknown reason")
260+
261+
detail.update(
262+
{
263+
"status": "failed",
264+
"reason": reason,
265+
"validation_result": validation_result,
266+
}
267+
)
268+
269+
# 2: Execute guardrail action
270+
try:
271+
await self.execute_guardrail(
272+
validation_result=validation_result,
273+
guardrail=guardrail,
274+
tool_name=tool_name,
275+
)
276+
detail["action_executed"] = True
277+
278+
except GuardrailViolationError:
279+
detail["action_executed"] = True
280+
detail["blocked"] = True
281+
raise
282+
283+
except Exception as action_error:
284+
detail["action_error"] = str(action_error)
285+
print(f"Action execution failed: {action_error}")
286+
287+
# Stop on first failure if requested
288+
if stop_on_first_failure:
289+
detail["stopped_early"] = True
290+
results["details"].append(detail)
291+
break
292+
293+
results["details"].append(detail)
294+
295+
except GuardrailViolationError:
296+
detail["status"] = "blocked"
297+
results["details"].append(detail)
298+
raise
299+
300+
except Exception as e:
301+
results["errors"] += 1
302+
results["all_passed"] = False
303+
detail.update({"status": "error", "error": str(e)})
304+
results["details"].append(detail)
305+
306+
# Summary
307+
print(" Processing Summary:")
308+
print(f" Total: {results['total_guardrails']}")
309+
print(f" Processed: {results['processed']}")
310+
print(f" Passed: {results['passed']}")
311+
print(f" Failed: {results['failed']}")
312+
print(f" Skipped: {results['skipped']}")
313+
print(f" Errors: {results['errors']}")
314+
print(f" All passed: {results['all_passed']}")
315+
316+
return results

0 commit comments

Comments
 (0)