From 61da2966991825f969312b2ccd6a7f90669648da Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Thu, 24 Sep 2026 00:29:41 +0300 Subject: [PATCH] fix(guardrails): resolve an unknown appliesTo scope to text only The backend renamed the text-only appliesTo value from Prompts to Text. The scope check here was a deny-list -- anything that was not the text-only spelling included files -- so the rename alone would have made a guardrail saved as "Prompts" start forwarding the run's files to a validator its author scoped to text. Matching a deny-list also puts the fail-open direction on the widening side: any value this version does not recognise resolves to "files apply". Match an allow-list of the scopes that include files instead. Only Files and Both forward references; every other present value resolves to text only. That covers the pre-rename Prompts spelling without naming it as a supported value, and covers any scope a newer backend introduces. An absent parameter is deliberately left alone: it carries no choice to respect, so the backend's per-validator default still decides. An empty or whitespace-only value is treated as absent, matching the backend, so both sides agree on which guardrails forward references. appliesTo has only ever been offered on alpha -- EnableGuardrailAttachments and EnableGuardrailLlmAsJudgeAttachments both default to false and have a flag file in the alpha ring only, and the definitions endpoint strips the parameter wherever file support is off -- so Prompts needs no compatibility guarantee, only a non-widening resolution. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 2 +- .../agent/guardrails/attachment_refs.py | 25 ++++--- .../agent/guardrails/test_attachment_refs.py | 75 ++++++++++++------- uv.lock | 2 +- 4 files changed, 62 insertions(+), 42 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 572ab6b09..01b693284 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.18.14" +version = "0.18.15" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath_langchain/agent/guardrails/attachment_refs.py b/src/uipath_langchain/agent/guardrails/attachment_refs.py index a0353cab0..1f915c72b 100644 --- a/src/uipath_langchain/agent/guardrails/attachment_refs.py +++ b/src/uipath_langchain/agent/guardrails/attachment_refs.py @@ -10,10 +10,10 @@ file forwards nothing, even when the run holds files elsewhere; otherwise every tool call would ship every file to the backend. -Any built-in guardrail forwards references unless it is scoped to prompts. The runtime -forwards id, file name and mime type only; the backend's feature flag decides whether -they are used at all, the backend decides which validators and file types it can -inspect, and it resolves the id through Orchestrator. +Any built-in guardrail forwards references unless its ``appliesTo`` scope is text only. +The runtime forwards id, file name and mime type only; the backend's feature flag +decides whether they are used at all, the backend decides which validators and file +types it can inspect, and it resolves the id through Orchestrator. Nothing in this module raises: the guardrail node re-raises any exception, which would end the run over a single malformed attachment. @@ -33,9 +33,9 @@ #: Limits enforced by the validate API. _MAX_ATTACHMENTS = 5 _MAX_FILE_NAME_LENGTH = 260 -#: ``appliesTo`` guardrail parameter; only ``Prompts`` excludes files (default is ``Both``). +#: ``appliesTo``: an allow-list, so an unknown value narrows to text only. Absent takes the backend default. _APPLIES_TO_PARAMETER = "appliesto" -_PROMPTS_ONLY = "prompts" +_FILE_SCOPES: frozenset[str] = frozenset({"files", "both"}) #: Wire key of a job attachment reference as the model and the tools exchange it. _ID_KEY = "ID" #: Bounds for scanning a tool payload, which can be arbitrarily large or deep. @@ -49,7 +49,10 @@ def _scope_includes_files(guardrail: BuiltInValidatorGuardrail) -> bool: if parameter.id.lower() != _APPLIES_TO_PARAMETER: continue if isinstance(parameter.value, str): - return parameter.value.strip().lower() != _PROMPTS_ONLY + # Empty means absent, as the backend reads it. + scope = parameter.value.strip().lower() + if scope: + return scope in _FILE_SCOPES except Exception: logger.debug( "Could not read the guardrail scope; assuming files apply.", exc_info=True @@ -64,14 +67,14 @@ async def resolve_guardrail_attachments( """Return up to five references for every attachment the run knows about. For Agent- and LLM-scope guardrails, which evaluate the conversation as a whole. - Empty when the guardrail is scoped to prompts or the run has no attachments. Never + Empty when the guardrail is scoped to text only or the run has no attachments. Never raises. """ if not job_attachments: return [] if not _scope_includes_files(guardrail): logger.debug( - "Guardrail '%s' is scoped to prompts; skipping attachment resolution.", + "Guardrail '%s' is scoped to text only; skipping attachment resolution.", guardrail.name, ) return [] @@ -92,13 +95,13 @@ def resolve_referenced_attachments( set of files this run legitimately has (agent input plus files its tools returned), so a mention the run never held, or a non-attachment resource id, is skipped rather than sent to the backend for lookup. Empty when nothing is mentioned or the guardrail - is scoped to prompts. Never raises. + is scoped to text only. Never raises. """ if data is None: return [] if not _scope_includes_files(guardrail): logger.debug( - "Guardrail '%s' is scoped to prompts; skipping attachment resolution.", + "Guardrail '%s' is scoped to text only; skipping attachment resolution.", guardrail.name, ) return [] diff --git a/tests/agent/guardrails/test_attachment_refs.py b/tests/agent/guardrails/test_attachment_refs.py index b8e67b976..5b8149b8e 100644 --- a/tests/agent/guardrails/test_attachment_refs.py +++ b/tests/agent/guardrails/test_attachment_refs.py @@ -26,17 +26,46 @@ def _judge() -> MagicMock: return guardrail -def _scoped_judge(applies_to: str, parameter_id: str = "appliesTo") -> MagicMock: - """A judge guardrail carrying the ``appliesTo`` parameter the designer writes.""" +def _scoped_judge(applies_to: str | None, parameter_id: str = "appliesTo") -> MagicMock: + """A judge guardrail carrying the ``appliesTo`` parameter the designer writes. + + ``applies_to=None`` carries no ``appliesTo`` parameter at all. + """ guardrail = _judge() - guardrail.validator_parameters = [ - EnumParameterValue.model_validate( - {"$parameterType": "enum", "id": parameter_id, "value": applies_to} - ) - ] + guardrail.validator_parameters = ( + [] + if applies_to is None + else [ + EnumParameterValue.model_validate( + {"$parameterType": "enum", "id": parameter_id, "value": applies_to} + ) + ] + ) return guardrail +#: ``appliesTo`` values and whether files stay in scope. ``None`` is a guardrail with no such +#: parameter, which leaves the decision to the backend's default. +_SCOPE_CASES = [ + ("Text", False), + ("text", False), + (" TEXT ", False), + # The pre-rename spelling: no longer recognised, but must still resolve to text only. + ("Prompts", False), + ("prompts", False), + (" PROMPTS ", False), + ("something-we-never-shipped", False), + ("", True), + (" ", True), + ("Files", True), + ("files", True), + (" FILES ", True), + ("Both", True), + ("both", True), + (None, True), +] + + def _registry(mime: str = "text/csv", name: str = "a.csv") -> dict[str, Attachment]: return {_UUID: Attachment(id=uuid.UUID(_UUID), full_name=name, mime_type=mime)} @@ -140,38 +169,26 @@ async def test_truncates_over_long_file_names_to_the_api_ceiling(self, monkeypat assert len(result[0].file_name) == 260 - @pytest.mark.parametrize("applies_to", ["Prompts", "prompts", " PROMPTS "]) - async def test_returns_empty_when_scoped_to_prompts(self, monkeypatch, applies_to): - """A prompts-only guardrail must not forward any file reference.""" + @pytest.mark.parametrize("applies_to,includes_files", _SCOPE_CASES) + async def test_honors_the_applies_to_scope( + self, monkeypatch, applies_to, includes_files + ): + """Only Files and Both forward references; anything else present is text only.""" result = await resolve_guardrail_attachments( _registry(), _scoped_judge(applies_to) ) - assert result == [] + assert [r.file_name for r in result] == (["a.csv"] if includes_files else []) async def test_matches_the_scope_parameter_id_case_insensitively(self, monkeypatch): """The backend matches parameter ids ignoring case; a mismatch here would resolve files the author scoped out.""" result = await resolve_guardrail_attachments( - _registry(), _scoped_judge("Prompts", parameter_id="AppliesTo") + _registry(), _scoped_judge("Text", parameter_id="AppliesTo") ) assert result == [] - @pytest.mark.parametrize( - "applies_to", ["Files", "Both", "both", "something-we-never-shipped"] - ) - async def test_resolves_when_the_scope_is_not_prompts_only( - self, monkeypatch, applies_to - ): - """Anything but Prompts keeps files in scope, matching the backend's default of Both. - An unrecognized value must not silently stop scanning files.""" - result = await resolve_guardrail_attachments( - _registry(), _scoped_judge(applies_to) - ) - - assert [r.file_name for r in result] == ["a.csv"] - async def test_resolves_when_the_scope_parameter_is_malformed(self, monkeypatch): """Never raises: the caller re-raises, which would end the run over a bad parameter.""" guardrail = _judge() @@ -271,13 +288,13 @@ async def test_caps_at_the_api_limit(self): assert len(result) == _MAX_ATTACHMENTS - @pytest.mark.parametrize("applies_to", ["Prompts", "prompts"]) - async def test_returns_empty_when_scoped_to_prompts(self, applies_to): + @pytest.mark.parametrize("applies_to,includes_files", _SCOPE_CASES) + async def test_honors_the_applies_to_scope(self, applies_to, includes_files): result = resolve_referenced_attachments( {"attachment": {"ID": _UUID}}, _registry(), _scoped_judge(applies_to) ) - assert result == [] + assert [r.file_name for r in result] == (["a.csv"] if includes_files else []) async def test_accepts_attachment_instances_and_models(self): """Arguments may already carry expanded objects, not only wire dicts; they are diff --git a/uv.lock b/uv.lock index b41ef45ca..fdc6bc03c 100644 --- a/uv.lock +++ b/uv.lock @@ -4828,7 +4828,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.18.14" +version = "0.18.15" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },