Skip to content

Commit f87bc01

Browse files
fix(guardrails): build attachment references per entry and trim comments
A malformed registry value could raise while being pre-filtered, and an invalid candidate consumed one of the five reference slots. References are now built per entry with safe attribute access and the loop stops after five successfully built ones. Module and node comments cut to what is needed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 4e96c81 commit f87bc01

3 files changed

Lines changed: 53 additions & 101 deletions

File tree

‎src/uipath_langchain/agent/guardrails/attachment_refs.py‎

Lines changed: 27 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,37 @@
11
"""Project a run's job-attachment registry into guardrail attachment references.
22
3-
Only the ``llm_as_judge`` validator consumes attachments today, only behind a feature flag,
4-
and only when the guardrail's author scoped it to files, so all three gates live here: there
5-
is no point building a reference for a validator -- or a guardrail configuration -- that will
6-
ignore the result.
7-
8-
The runtime no longer resolves attachments to a URL. It only forwards the Orchestrator
9-
attachment id, file name and mime type; helix resolves the id through its own Orchestrator
10-
client (folder-scoped when the run's folder key is available), so Orchestrator's own access
11-
control decides whether the file can be read at all. That also means this module makes no
12-
network call of its own any more -- there is nothing here that can fail transiently.
13-
14-
Nothing in this module raises. The low-code guardrail node re-raises any exception it sees
15-
(see ``guardrail_nodes._create_guardrail_node``), which terminates the agent run — so a
16-
malformed attachment must never escape. An unreadable file degrades to "the guardrail
17-
evaluates the text payload alone", which is the safer failure direction for a guardrail.
3+
Only the ``llm_as_judge`` validator uses attachments, behind a feature flag, and only when
4+
the guardrail is not scoped to prompts. The runtime forwards id, file name and mime type;
5+
the backend resolves the id through Orchestrator.
6+
7+
Nothing in this module raises: the guardrail node re-raises any exception, which would end
8+
the run over a single malformed attachment.
189
"""
1910

2011
import logging
2112
import uuid
13+
from typing import Any
2214

2315
from uipath.core.feature_flags import FeatureFlags
2416
from uipath.platform.attachments import Attachment
2517
from uipath.platform.guardrails import BuiltInValidatorGuardrail, GuardrailAttachment
2618

2719
logger = logging.getLogger(__name__)
2820

29-
#: Kill switch. Names are case-sensitive and the env var is an exact concatenation:
30-
#: ``UIPATH_FEATURE_GuardrailAttachmentsEnabled``.
21+
#: Env var: ``UIPATH_FEATURE_GuardrailAttachmentsEnabled`` (case-sensitive).
3122
GUARDRAIL_ATTACHMENTS_FEATURE_FLAG = "GuardrailAttachmentsEnabled"
3223

33-
#: The only validator that can read file contents today. The backend enforces this too.
3424
_LLM_AS_JUDGE = "llm_as_judge"
35-
36-
#: Matches the ceiling the validate API enforces; resolving more would be wasted work.
25+
#: Limits enforced by the validate API.
3726
_MAX_ATTACHMENTS = 5
38-
39-
#: The validate API rejects longer file names with a 400.
4027
_MAX_FILE_NAME_LENGTH = 260
41-
42-
#: Optional guardrail parameter scoping the evaluation to ``Prompts``, ``Files`` or ``Both``.
43-
#: Parameter ids are matched case-insensitively, as the backend does.
28+
#: ``appliesTo`` guardrail parameter; only ``Prompts`` excludes files (default is ``Both``).
4429
_APPLIES_TO_PARAMETER = "appliesto"
45-
46-
#: The one value that puts files out of scope. Anything else -- including an absent parameter,
47-
#: which is every guardrail configured before it existed -- keeps them in, matching the
48-
#: backend's default of "Both". Narrowing on an unrecognized value would silently stop
49-
#: scanning files for a guardrail whose author never asked for that.
5030
_PROMPTS_ONLY = "prompts"
5131

52-
#: What the backend can inspect. The runtime only forwards references — the backend decides
53-
#: how to read each type, so this set exists to avoid spending an Orchestrator round-trip on a
54-
#: file that would be skipped anyway.
32+
#: Types the backend can inspect; anything else is not worth forwarding.
5533
SUPPORTED_MIME_TYPES = frozenset(
5634
{
57-
# Already text: the backend decodes these and inlines them into the judged payload.
5835
"text/plain",
5936
"text/csv",
6037
"application/csv",
@@ -63,7 +40,6 @@
6340
"application/json",
6441
"text/xml",
6542
"application/xml",
66-
# Binary: the backend sends these to a vision-capable judge model as content parts.
6743
"application/pdf",
6844
"image/png",
6945
"image/jpeg",
@@ -74,7 +50,6 @@
7450

7551

7652
def _is_enabled(guardrail: BuiltInValidatorGuardrail) -> bool:
77-
"""Both gates: the validator must be the judge and the feature flag must be on."""
7853
if getattr(guardrail, "validator_type", None) != _LLM_AS_JUDGE:
7954
return False
8055
return FeatureFlags.is_flag_enabled(
@@ -83,12 +58,6 @@ def _is_enabled(guardrail: BuiltInValidatorGuardrail) -> bool:
8358

8459

8560
def _scope_includes_files(guardrail: BuiltInValidatorGuardrail) -> bool:
86-
"""Whether the guardrail's ``appliesTo`` parameter puts the run's files in scope.
87-
88-
The backend gates on this too, but only after helix has already resolved each id
89-
through Orchestrator. Reading it here is what actually avoids sending references --
90-
and the Orchestrator lookups they trigger downstream -- for a prompts-only guardrail.
91-
"""
9261
try:
9362
for parameter in getattr(guardrail, "validator_parameters", None) or []:
9463
if str(getattr(parameter, "id", "")).lower() != _APPLIES_TO_PARAMETER:
@@ -97,8 +66,6 @@ def _scope_includes_files(guardrail: BuiltInValidatorGuardrail) -> bool:
9766
if isinstance(value, str):
9867
return value.strip().lower() != _PROMPTS_ONLY
9968
except Exception:
100-
# This module promises never to raise: the caller re-raises, which ends the run. A
101-
# parameter list that is not shaped as expected falls back to the backend's default.
10269
logger.debug(
10370
"Could not read the guardrail scope; assuming files apply.", exc_info=True
10471
)
@@ -109,16 +76,10 @@ async def resolve_guardrail_attachments(
10976
job_attachments: dict[str, Attachment],
11077
guardrail: BuiltInValidatorGuardrail,
11178
) -> list[GuardrailAttachment]:
112-
"""Resolve the run's job attachments into references the guardrails API can read.
79+
"""Return up to five attachment references for the guardrail, or an empty list.
11380
114-
Args:
115-
job_attachments: The per-run registry from ``state.inner_state.job_attachments``.
116-
guardrail: The guardrail about to be evaluated; gates on its validator type.
117-
118-
Returns:
119-
Attachment references, or an empty list when the feature is off, the validator
120-
cannot use them, the guardrail is scoped to prompts only, or none are of a
121-
supported type. Never raises.
81+
Empty when the feature is off, the validator is not the judge, the guardrail is scoped
82+
to prompts, or no attachment is of a supported type. Never raises.
12283
"""
12384
if not job_attachments or not _is_enabled(guardrail):
12485
return []
@@ -129,48 +90,32 @@ async def resolve_guardrail_attachments(
12990
)
13091
return []
13192

132-
candidates = [
133-
attachment
134-
for attachment in job_attachments.values()
135-
if attachment.id is not None
136-
and (attachment.mime_type or "").lower() in SUPPORTED_MIME_TYPES
137-
][:_MAX_ATTACHMENTS]
138-
13993
references: list[GuardrailAttachment] = []
140-
for attachment in candidates:
94+
for attachment in job_attachments.values():
14195
reference = _to_reference(attachment)
14296
if reference is not None:
14397
references.append(reference)
98+
if len(references) == _MAX_ATTACHMENTS:
99+
break
144100
return references
145101

146102

147-
def _to_reference(attachment: Attachment) -> GuardrailAttachment | None:
148-
"""Project one job attachment into a reference, or None if it cannot be forwarded."""
149-
attachment_id = str(attachment.id)
150-
try:
151-
uuid.UUID(attachment_id)
152-
except (ValueError, AttributeError, TypeError):
153-
# The validate API requires a GUID; helix has nothing to resolve otherwise.
154-
logger.warning(
155-
"Attachment '%s' has a non-UUID id; skipping for guardrail inspection.",
156-
getattr(attachment, "full_name", "?"),
157-
)
158-
return None
103+
def _to_reference(attachment: Any) -> GuardrailAttachment | None:
104+
"""Build one reference, or None when the attachment cannot be forwarded."""
159105
try:
160-
# The validate API caps file names at 260 characters; a longer name would be a 400
161-
# for the whole request, and this is a display label, not an identifier.
162-
file_name = (attachment.full_name or "")[:_MAX_FILE_NAME_LENGTH]
106+
mime_type = str(getattr(attachment, "mime_type", "") or "")
107+
if mime_type.lower() not in SUPPORTED_MIME_TYPES:
108+
return None
109+
attachment_id = str(uuid.UUID(str(getattr(attachment, "id", None))))
110+
file_name = str(getattr(attachment, "full_name", "") or "")
163111
return GuardrailAttachment(
164112
id=attachment_id,
165-
file_name=file_name,
166-
mime_type=attachment.mime_type,
113+
file_name=file_name[:_MAX_FILE_NAME_LENGTH],
114+
mime_type=mime_type,
167115
)
168116
except Exception:
169-
# Deliberately broad: this module promises never to raise, and the caller
170-
# re-raises, which would end the run over a single malformed attachment.
171117
logger.warning(
172-
"Could not build a guardrail reference for attachment '%s'; "
173-
"the guardrail will evaluate without it.",
118+
"Skipping attachment '%s' for guardrail inspection: invalid reference.",
174119
getattr(attachment, "full_name", "?"),
175120
exc_info=True,
176121
)

‎src/uipath_langchain/agent/guardrails/guardrail_nodes.py‎

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
logger = logging.getLogger(__name__)
3939

40-
#: Guardrail scopes whose evaluations may inspect attached files. Deliberately not TOOL.
40+
#: Scopes whose guardrails may inspect attached files (tool scope excluded on purpose).
4141
_ATTACHMENT_SCOPES = frozenset({GuardrailScope.AGENT, GuardrailScope.LLM})
4242

4343

@@ -83,14 +83,6 @@ async def _evaluate_builtin_guardrail(
8383
):
8484
"""Evaluate built-in validator guardrail.
8585
86-
Takes the already-generated payload rather than the generator: the caller needs the
87-
same payload for observability metadata, and generating it twice would double any
88-
work the generator does.
89-
90-
``evaluate_guardrail`` is a synchronous HTTP call, so it is offloaded to a thread
91-
rather than blocking the event loop for the whole round-trip — which now includes the
92-
backend fetching and decoding each attachment.
93-
9486
Args:
9587
guardrail: The built-in validator guardrail to evaluate.
9688
text: The payload text to validate.
@@ -108,11 +100,8 @@ async def _evaluate_builtin_guardrail(
108100
attachments=attachments,
109101
)
110102
except EnrichedException as exc:
111-
# A 400 on a request that carried attachments means the backend rejected the
112-
# attachment references themselves (bad id, oversize name, unresolvable
113-
# attachment...). A guardrail must never fail the run because of a file, so
114-
# evaluate the text payload alone. Any other status is a genuine failure and
115-
# propagates as before.
103+
# A 400 with attachments means the references were rejected; a file must never
104+
# fail the run, so evaluate the text alone.
116105
if not attachments or exc.status_code != 400:
117106
raise
118107
logger.warning(
@@ -239,17 +228,13 @@ async def node(
239228
output_data_extractor,
240229
)
241230
elif isinstance(guardrail, BuiltInValidatorGuardrail):
242-
# Generate and store payload for observability. Generated once and passed
243-
# down: it used to run again inside _evaluate_builtin_guardrail.
231+
# Generate and store payload for observability
244232
payload = payload_generator(state)
245233
if execution_stage == ExecutionStage.PRE_EXECUTION:
246234
metadata["payload"]["input"] = payload
247235
else:
248236
metadata["payload"]["output"] = payload
249237

250-
# File contents reach the judge at Agent and LLM scope only. Tool scope is
251-
# excluded on purpose: a tool-scope judge would forward attachment
252-
# references on every tool call, over a registry that only grows.
253238
attachments = (
254239
await resolve_guardrail_attachments(
255240
state.inner_state.job_attachments, guardrail

‎tests/agent/guardrails/test_attachment_refs.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for projecting the job-attachment registry into guardrail attachment refs."""
22

33
import uuid
4+
from typing import Any
45
from unittest.mock import MagicMock
56

67
import pytest
@@ -9,6 +10,7 @@
910
from uipath.platform.guardrails.guardrails import EnumParameterValue
1011

1112
from uipath_langchain.agent.guardrails.attachment_refs import (
13+
_MAX_ATTACHMENTS,
1214
GUARDRAIL_ATTACHMENTS_FEATURE_FLAG,
1315
resolve_guardrail_attachments,
1416
)
@@ -121,6 +123,26 @@ async def test_caps_attachment_count(self, monkeypatch):
121123

122124
assert len(result) == 5
123125

126+
async def test_malformed_entry_neither_raises_nor_consumes_a_slot(
127+
self, monkeypatch
128+
):
129+
"""One bad registry value must not end the run or hide a later valid file."""
130+
monkeypatch.setenv(_ENV_FLAG, "true")
131+
registry: dict[str, Any] = {"bad": object()}
132+
for index in range(_MAX_ATTACHMENTS):
133+
attachment_id = str(uuid.uuid4())
134+
registry[attachment_id] = Attachment(
135+
id=uuid.UUID(attachment_id),
136+
full_name=f"{index}.csv",
137+
mime_type="text/csv",
138+
)
139+
140+
result = await resolve_guardrail_attachments(registry, _judge())
141+
142+
assert [a.file_name for a in result] == [
143+
f"{i}.csv" for i in range(_MAX_ATTACHMENTS)
144+
]
145+
124146
async def test_returns_empty_for_empty_registry(self, monkeypatch):
125147
monkeypatch.setenv(_ENV_FLAG, "true")
126148

0 commit comments

Comments
 (0)