Skip to content

Commit 5211e58

Browse files
author
Valentina Bojan
committed
feat(guardrails): add AGENT_INPUT field source for pre-execution rules
Lets deterministic guardrail rules reference the agent's validated input parameters via FieldSource.AGENT_INPUT (e.g. caller-context gating: role/tier/region/dry-run). Pre-execution only — agent_input is not threaded into post evaluation; a config validator rejects guardrails that mix AGENT_INPUT with output-dependent rules. AL-410 / AL-405 (Phase A).
1 parent ae5dac0 commit 5211e58

4 files changed

Lines changed: 267 additions & 10 deletions

File tree

packages/uipath-core/src/uipath/core/guardrails/_deterministic_guardrails_service.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,18 @@ def evaluate_pre_deterministic_guardrail(
3030
self,
3131
input_data: dict[str, Any],
3232
guardrail: DeterministicGuardrail,
33+
*,
34+
agent_input: dict[str, Any] | None = None,
3335
) -> GuardrailValidationResult:
34-
"""Evaluate deterministic guardrail rules against input data (pre-execution)."""
36+
"""Evaluate deterministic guardrail rules against input data (pre-execution).
37+
38+
Args:
39+
input_data: Tool input data being validated.
40+
guardrail: The deterministic guardrail to evaluate.
41+
agent_input: The agent's validated input parameters. Available only
42+
in pre-execution; rules with ``FieldSource.AGENT_INPUT`` resolve
43+
their values from this dict.
44+
"""
3545
# Check if guardrail contains any output-dependent rules
3646
has_output_rule = self._has_output_dependent_rule(guardrail, [ApplyTo.OUTPUT])
3747

@@ -46,6 +56,7 @@ def evaluate_pre_deterministic_guardrail(
4656
input_data=input_data,
4757
output_data={},
4858
guardrail=guardrail,
59+
agent_input=agent_input,
4960
)
5061

5162
@traced("evaluate_post_deterministic_guardrails", run_type="uipath")
@@ -116,6 +127,7 @@ def _evaluate_deterministic_guardrail(
116127
input_data: dict[str, Any],
117128
output_data: dict[str, Any],
118129
guardrail: DeterministicGuardrail,
130+
agent_input: dict[str, Any] | None = None,
119131
) -> GuardrailValidationResult:
120132
"""Evaluate deterministic guardrail rules against input and output data.
121133
@@ -125,11 +137,17 @@ def _evaluate_deterministic_guardrail(
125137

126138
for rule in guardrail.rules:
127139
if isinstance(rule, WordRule):
128-
passed, reason = evaluate_word_rule(rule, input_data, output_data)
140+
passed, reason = evaluate_word_rule(
141+
rule, input_data, output_data, agent_input
142+
)
129143
elif isinstance(rule, NumberRule):
130-
passed, reason = evaluate_number_rule(rule, input_data, output_data)
144+
passed, reason = evaluate_number_rule(
145+
rule, input_data, output_data, agent_input
146+
)
131147
elif isinstance(rule, BooleanRule):
132-
passed, reason = evaluate_boolean_rule(rule, input_data, output_data)
148+
passed, reason = evaluate_boolean_rule(
149+
rule, input_data, output_data, agent_input
150+
)
133151
elif isinstance(rule, UniversalRule):
134152
passed, reason = evaluate_universal_rule(rule, output_data)
135153
else:

packages/uipath-core/src/uipath/core/guardrails/_evaluators.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ def get_fields_from_selector(
135135
field_selector: AllFieldsSelector | SpecificFieldsSelector,
136136
input_data: dict[str, Any],
137137
output_data: dict[str, Any],
138+
agent_input: dict[str, Any] | None = None,
138139
) -> list[tuple[Any, FieldReference]]:
139140
"""Get field values and their references based on the field selector."""
140141
fields: list[tuple[Any, FieldReference]] = []
@@ -159,6 +160,14 @@ def get_fields_from_selector(
159160
FieldReference(path=key, source=FieldSource.OUTPUT),
160161
)
161162
)
163+
if FieldSource.AGENT_INPUT in field_selector.sources and agent_input:
164+
for key, value in agent_input.items():
165+
fields.append(
166+
(
167+
value,
168+
FieldReference(path=key, source=FieldSource.AGENT_INPUT),
169+
)
170+
)
162171
elif isinstance(field_selector, SpecificFieldsSelector):
163172
# For specific fields, extract values based on field references
164173
for field_ref in field_selector.fields:
@@ -167,6 +176,10 @@ def get_fields_from_selector(
167176
data = input_data
168177
elif field_ref.source == FieldSource.OUTPUT:
169178
data = output_data
179+
elif field_ref.source == FieldSource.AGENT_INPUT:
180+
if agent_input is None:
181+
continue
182+
data = agent_input
170183
else:
171184
# Unknown source, skip this field
172185
continue
@@ -185,7 +198,12 @@ def format_guardrail_passed_validation_result_message(
185198
rule_description: str | None,
186199
) -> str:
187200
"""Format a guardrail validation result message following the standard pattern."""
188-
source = "Input" if field_ref.source == FieldSource.INPUT else "Output"
201+
if field_ref.source == FieldSource.INPUT:
202+
source = "Input"
203+
elif field_ref.source == FieldSource.AGENT_INPUT:
204+
source = "Agent input"
205+
else:
206+
source = "Output"
189207

190208
if rule_description:
191209
return (
@@ -211,10 +229,15 @@ def get_validated_conditions_description(
211229

212230

213231
def evaluate_word_rule(
214-
rule: WordRule, input_data: dict[str, Any], output_data: dict[str, Any]
232+
rule: WordRule,
233+
input_data: dict[str, Any],
234+
output_data: dict[str, Any],
235+
agent_input: dict[str, Any] | None = None,
215236
) -> tuple[bool, str]:
216237
"""Evaluate a word rule against input and output data."""
217-
fields = get_fields_from_selector(rule.field_selector, input_data, output_data)
238+
fields = get_fields_from_selector(
239+
rule.field_selector, input_data, output_data, agent_input
240+
)
218241
if not fields:
219242
return True, "No fields to validate"
220243

@@ -256,10 +279,15 @@ def evaluate_word_rule(
256279

257280

258281
def evaluate_number_rule(
259-
rule: NumberRule, input_data: dict[str, Any], output_data: dict[str, Any]
282+
rule: NumberRule,
283+
input_data: dict[str, Any],
284+
output_data: dict[str, Any],
285+
agent_input: dict[str, Any] | None = None,
260286
) -> tuple[bool, str]:
261287
"""Evaluate a number rule against input and output data."""
262-
fields = get_fields_from_selector(rule.field_selector, input_data, output_data)
288+
fields = get_fields_from_selector(
289+
rule.field_selector, input_data, output_data, agent_input
290+
)
263291
if not fields:
264292
return True, "No fields to validate"
265293

@@ -304,9 +332,12 @@ def evaluate_boolean_rule(
304332
rule: BooleanRule,
305333
input_data: dict[str, Any],
306334
output_data: dict[str, Any],
335+
agent_input: dict[str, Any] | None = None,
307336
) -> tuple[bool, str]:
308337
"""Evaluate a boolean rule against input and output data."""
309-
fields = get_fields_from_selector(rule.field_selector, input_data, output_data)
338+
fields = get_fields_from_selector(
339+
rule.field_selector, input_data, output_data, agent_input
340+
)
310341
if not fields:
311342
return True, "No fields to validate"
312343

packages/uipath-core/src/uipath/core/guardrails/guardrails.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class FieldSource(str, Enum):
4747

4848
INPUT = "input"
4949
OUTPUT = "output"
50+
AGENT_INPUT = "agentInput"
5051

5152

5253
class ApplyTo(str, Enum):
@@ -239,3 +240,33 @@ class DeterministicGuardrail(BaseGuardrail):
239240
rules: list[Rule]
240241

241242
model_config = ConfigDict(populate_by_name=True, extra="allow")
243+
244+
@field_validator("rules")
245+
@classmethod
246+
def _agent_input_is_pre_execution_only(cls, rules: list[Rule]) -> list[Rule]:
247+
# Guardrails are dispatched pre or post based on whether ANY rule has an
248+
# OUTPUT-dependent reference; agent_input is not threaded into post, so a
249+
# guardrail mixing agent_input with output sources would silently no-op
250+
# the agent_input rules in post. Reject at config load.
251+
sources: set[FieldSource] = set()
252+
has_output_universal = False
253+
for rule in rules:
254+
if isinstance(rule, (WordRule, NumberRule, BooleanRule)):
255+
selector = rule.field_selector
256+
if isinstance(selector, SpecificFieldsSelector):
257+
sources.update(f.source for f in selector.fields)
258+
elif isinstance(selector, AllFieldsSelector):
259+
sources.update(selector.sources)
260+
elif isinstance(rule, UniversalRule):
261+
if rule.apply_to in (ApplyTo.OUTPUT, ApplyTo.INPUT_AND_OUTPUT):
262+
has_output_universal = True
263+
264+
if FieldSource.AGENT_INPUT in sources and (
265+
FieldSource.OUTPUT in sources or has_output_universal
266+
):
267+
raise ValueError(
268+
"A guardrail referencing the 'agentInput' field source cannot "
269+
"also have output-dependent rules. agent_input is available "
270+
"only in pre-execution."
271+
)
272+
return rules

packages/uipath-core/tests/guardrails/test_deterministic_guardrails_service.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1674,3 +1674,180 @@ def test_word_rule_on_wrapped_array_output_passes_when_no_match(
16741674
guardrail=guardrail,
16751675
)
16761676
assert result.result == GuardrailValidationResultType.PASSED
1677+
1678+
1679+
class TestAgentInputFieldSource:
1680+
"""AGENT_INPUT field source: rules can reference the agent's validated
1681+
input parameters during pre-execution evaluation."""
1682+
1683+
@staticmethod
1684+
def _user_identity_guardrail() -> DeterministicGuardrail:
1685+
return DeterministicGuardrail(
1686+
id="block-non-admin-callers",
1687+
name="Block non-admin callers",
1688+
description="Caller-context RBAC: only 'admin' role may proceed",
1689+
enabled_for_evals=True,
1690+
guardrail_type="custom",
1691+
selector=GuardrailSelector(
1692+
scopes=[GuardrailScope.TOOL], match_names=["any_tool"]
1693+
),
1694+
rules=[
1695+
WordRule(
1696+
rule_type="word",
1697+
field_selector=SpecificFieldsSelector(
1698+
selector_type="specific",
1699+
fields=[
1700+
FieldReference(path="role", source=FieldSource.AGENT_INPUT)
1701+
],
1702+
),
1703+
detects_violation=lambda s: s != "admin",
1704+
rule_description="role must equal 'admin'",
1705+
),
1706+
],
1707+
)
1708+
1709+
def test_pre_evaluation_passes_when_agent_input_matches(
1710+
self, service: DeterministicGuardrailsService
1711+
) -> None:
1712+
result = service.evaluate_pre_deterministic_guardrail(
1713+
input_data={"target": "anything"},
1714+
guardrail=self._user_identity_guardrail(),
1715+
agent_input={"role": "admin"},
1716+
)
1717+
assert result.result == GuardrailValidationResultType.PASSED
1718+
1719+
def test_pre_evaluation_fails_when_agent_input_violates(
1720+
self, service: DeterministicGuardrailsService
1721+
) -> None:
1722+
result = service.evaluate_pre_deterministic_guardrail(
1723+
input_data={"target": "anything"},
1724+
guardrail=self._user_identity_guardrail(),
1725+
agent_input={"role": "viewer"},
1726+
)
1727+
assert result.result == GuardrailValidationResultType.VALIDATION_FAILED
1728+
1729+
def test_pre_evaluation_silent_pass_when_agent_input_omitted(
1730+
self, service: DeterministicGuardrailsService
1731+
) -> None:
1732+
# No agent_input passed → field selector resolves to no values → rule
1733+
# passes with "No fields to validate". This matches existing behavior
1734+
# for missing INPUT fields and avoids breaking older callers that don't
1735+
# yet pass agent_input.
1736+
result = service.evaluate_pre_deterministic_guardrail(
1737+
input_data={"target": "anything"},
1738+
guardrail=self._user_identity_guardrail(),
1739+
)
1740+
assert result.result == GuardrailValidationResultType.PASSED
1741+
1742+
def test_pre_evaluation_with_all_fields_selector(
1743+
self, service: DeterministicGuardrailsService
1744+
) -> None:
1745+
guardrail = DeterministicGuardrail(
1746+
id="dry-run-mode",
1747+
name="Block writes in dry-run mode",
1748+
enabled_for_evals=True,
1749+
guardrail_type="custom",
1750+
selector=GuardrailSelector(scopes=[GuardrailScope.TOOL]),
1751+
rules=[
1752+
BooleanRule(
1753+
rule_type="boolean",
1754+
field_selector=AllFieldsSelector(
1755+
selector_type="all",
1756+
sources=[FieldSource.AGENT_INPUT],
1757+
),
1758+
detects_violation=lambda b: b is True,
1759+
rule_description="dry_run must not be true",
1760+
),
1761+
],
1762+
)
1763+
result_blocked = service.evaluate_pre_deterministic_guardrail(
1764+
input_data={},
1765+
guardrail=guardrail,
1766+
agent_input={"dry_run": True},
1767+
)
1768+
assert result_blocked.result == GuardrailValidationResultType.VALIDATION_FAILED
1769+
1770+
result_allowed = service.evaluate_pre_deterministic_guardrail(
1771+
input_data={},
1772+
guardrail=guardrail,
1773+
agent_input={"dry_run": False},
1774+
)
1775+
assert result_allowed.result == GuardrailValidationResultType.PASSED
1776+
1777+
def test_validator_rejects_agent_input_with_output_in_same_rule(
1778+
self,
1779+
) -> None:
1780+
with pytest.raises(ValueError, match="agent_input is available only in pre"):
1781+
DeterministicGuardrail(
1782+
id="bad",
1783+
name="bad",
1784+
enabled_for_evals=True,
1785+
guardrail_type="custom",
1786+
rules=[
1787+
WordRule(
1788+
rule_type="word",
1789+
field_selector=SpecificFieldsSelector(
1790+
selector_type="specific",
1791+
fields=[
1792+
FieldReference(
1793+
path="role", source=FieldSource.AGENT_INPUT
1794+
),
1795+
FieldReference(
1796+
path="result", source=FieldSource.OUTPUT
1797+
),
1798+
],
1799+
),
1800+
detects_violation=lambda s: False,
1801+
),
1802+
],
1803+
)
1804+
1805+
def test_validator_rejects_agent_input_paired_with_output_universal_rule(
1806+
self,
1807+
) -> None:
1808+
with pytest.raises(ValueError, match="agent_input is available only in pre"):
1809+
DeterministicGuardrail(
1810+
id="bad",
1811+
name="bad",
1812+
enabled_for_evals=True,
1813+
guardrail_type="custom",
1814+
rules=[
1815+
WordRule(
1816+
rule_type="word",
1817+
field_selector=SpecificFieldsSelector(
1818+
selector_type="specific",
1819+
fields=[
1820+
FieldReference(
1821+
path="role", source=FieldSource.AGENT_INPUT
1822+
)
1823+
],
1824+
),
1825+
detects_violation=lambda s: False,
1826+
),
1827+
UniversalRule(
1828+
rule_type="always",
1829+
apply_to=ApplyTo.OUTPUT,
1830+
),
1831+
],
1832+
)
1833+
1834+
def test_post_evaluation_does_not_receive_agent_input(
1835+
self, service: DeterministicGuardrailsService
1836+
) -> None:
1837+
# A guardrail whose only rule references AGENT_INPUT has no
1838+
# output-dependent rule, so post-evaluation short-circuits to PASSED
1839+
# without consulting agent_input. This documents that agent_input is
1840+
# not threaded into post by design.
1841+
result = service.evaluate_post_deterministic_guardrail(
1842+
input_data={"target": "anything"},
1843+
output_data={"some": "output"},
1844+
guardrail=self._user_identity_guardrail(),
1845+
)
1846+
assert result.result == GuardrailValidationResultType.PASSED
1847+
assert result.reason == "No rules to apply for output data."
1848+
1849+
def test_field_reference_normalizes_pascalcase_agent_input(self) -> None:
1850+
# JSON config commonly uses PascalCase ("AgentInput"); the source
1851+
# field validator decapitalizes to the camelCase enum value.
1852+
ref = FieldReference(path="role", source="AgentInput") # type: ignore[arg-type]
1853+
assert ref.source == FieldSource.AGENT_INPUT

0 commit comments

Comments
 (0)