Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
[project]
name = "uipath-langchain"
version = "0.18.8"
version = "0.18.9"
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"
dependencies = [
"uipath>=2.14.19, <2.15.0",
"uipath-core>=0.5.29, <0.6.0",
"uipath-platform>=0.2.30, <0.3.0",
"uipath-platform>=0.2.31, <0.3.0",
"uipath-runtime>=0.13.0, <0.14.0",
"uipath-llm-client>=1.20.0, <1.21.0",
"langgraph>=1.2.11, <2.0.0",
Expand Down
95 changes: 95 additions & 0 deletions src/uipath_langchain/agent/guardrails/attachment_refs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Project a run's job-attachment registry into guardrail attachment references.

Any built-in guardrail forwards the run's attachments 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, and the backend decides which validators and file types it
can inspect and 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.
"""

import logging
import uuid
from typing import Any

from uipath.platform.attachments import Attachment
from uipath.platform.guardrails import BuiltInValidatorGuardrail, GuardrailAttachment

logger = logging.getLogger(__name__)

#: Limits enforced by the validate API.
_MAX_ATTACHMENTS = 5
_MAX_FILE_NAME_LENGTH = 260
#: ``appliesTo`` guardrail parameter; only ``Prompts`` excludes files (default is ``Both``).
_APPLIES_TO_PARAMETER = "appliesto"
_PROMPTS_ONLY = "prompts"


def _scope_includes_files(guardrail: BuiltInValidatorGuardrail) -> bool:
try:
for parameter in guardrail.validator_parameters:
if parameter.id.lower() != _APPLIES_TO_PARAMETER:
continue
if isinstance(parameter.value, str):
return parameter.value.strip().lower() != _PROMPTS_ONLY
except Exception:
logger.debug(
"Could not read the guardrail scope; assuming files apply.", exc_info=True
)
return True


async def resolve_guardrail_attachments(

Check warning on line 43 in src/uipath_langchain/agent/guardrails/attachment_refs.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use asynchronous features in this function or remove the `async` keyword.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AaCt0raoOC7F2a_-Uq3o&open=AaCt0raoOC7F2a_-Uq3o&pullRequest=1082
job_attachments: dict[str, Attachment],
guardrail: BuiltInValidatorGuardrail,
) -> list[GuardrailAttachment]:
"""Return up to five attachment references for the guardrail, or an empty list.

Empty when the guardrail is scoped to prompts 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.name,
)
return []

references: list[GuardrailAttachment] = []
for attachment in job_attachments.values():
reference = _to_reference(attachment)
if reference is not None:
references.append(reference)
if len(references) == _MAX_ATTACHMENTS:
break
return references


def _to_reference(attachment: Any) -> GuardrailAttachment | None:
"""Build one reference, or None when the attachment cannot be forwarded."""
try:
attachment_id = str(uuid.UUID(str(getattr(attachment, "id", None))))
file_name = str(getattr(attachment, "full_name", "") or "")
mime_type = str(getattr(attachment, "mime_type", "") or "")
if not file_name or not mime_type:
# The validate API rejects the whole request over an empty name or type.
logger.debug(
"Skipping attachment '%s' for guardrail inspection: missing name or type.",
file_name or attachment_id,
)
return None
return GuardrailAttachment(
id=attachment_id,
file_name=file_name[:_MAX_FILE_NAME_LENGTH],
mime_type=mime_type,
)
except Exception:
logger.warning(
"Skipping attachment '%s' for guardrail inspection: invalid reference.",
getattr(attachment, "full_name", "?"),
exc_info=True,
)
return None
53 changes: 44 additions & 9 deletions src/uipath_langchain/agent/guardrails/guardrail_nodes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import json
import logging
import re
Expand All @@ -11,13 +12,18 @@
GuardrailValidationResultType,
)
from uipath.platform import UiPath
from uipath.platform.errors import EnrichedException
from uipath.platform.guardrails import (
BaseGuardrail,
BuiltInValidatorGuardrail,
GuardrailAttachment,
GuardrailScope,
)
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain.agent.guardrails.attachment_refs import (
resolve_guardrail_attachments,
)
from uipath_langchain.agent.guardrails.types import ExecutionStage
from uipath_langchain.agent.guardrails.utils import (
_extract_tool_args_from_message,
Expand All @@ -31,6 +37,9 @@

logger = logging.getLogger(__name__)

#: Scopes whose guardrails may inspect attached files (tool scope excluded on purpose).
_ATTACHMENT_SCOPES = frozenset({GuardrailScope.AGENT, GuardrailScope.LLM})
Comment thread
apetraru-uipath marked this conversation as resolved.


def _evaluate_deterministic_guardrail(
state: AgentGuardrailsGraphState,
Expand Down Expand Up @@ -67,24 +76,42 @@ def _evaluate_deterministic_guardrail(
)


def _evaluate_builtin_guardrail(
state: AgentGuardrailsGraphState,
async def _evaluate_builtin_guardrail(
guardrail: BuiltInValidatorGuardrail,
payload_generator: Callable[[AgentGuardrailsGraphState], str],
text: str,
attachments: list[GuardrailAttachment] | None = None,
):
"""Evaluate built-in validator guardrail.

Args:
state: The current agent graph state.
guardrail: The built-in validator guardrail to evaluate.
payload_generator: Function to generate payload text from state.
text: The payload text to validate.
attachments: Resolved attachment references the validator may inspect.

Returns:
The guardrail evaluation result.
"""
text = payload_generator(state)
uipath = UiPath()
return uipath.guardrails.evaluate_guardrail(text, guardrail)
try:
return await asyncio.to_thread(
uipath.guardrails.evaluate_guardrail,
text,
guardrail,
attachments=attachments,
)
except EnrichedException as exc:
# A 400 with attachments means the references were rejected; a file must never
# fail the run, so evaluate the text alone.
if not attachments or exc.status_code != 400:
raise
logger.warning(
"Guardrail '%s' rejected the attachment references (HTTP 400); "
"re-evaluating without attachments.",
guardrail.name,
)
return await asyncio.to_thread(
uipath.guardrails.evaluate_guardrail, text, guardrail, attachments=None
)


def _create_validation_command(
Expand Down Expand Up @@ -208,8 +235,16 @@ async def node(
else:
metadata["payload"]["output"] = payload

result = _evaluate_builtin_guardrail(
state, guardrail, payload_generator
attachments = (
await resolve_guardrail_attachments(
state.inner_state.job_attachments, guardrail
)
if scope in _ATTACHMENT_SCOPES
else []
)

result = await _evaluate_builtin_guardrail(
guardrail, payload, attachments
)
else:
# Provide specific error message for DeterministicGuardrails with wrong scope
Expand Down
180 changes: 180 additions & 0 deletions tests/agent/guardrails/test_attachment_refs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""Tests for projecting the job-attachment registry into guardrail attachment refs."""

import uuid
from typing import Any
from unittest.mock import MagicMock

import pytest
from uipath.platform.attachments import Attachment
from uipath.platform.guardrails import BuiltInValidatorGuardrail
from uipath.platform.guardrails.guardrails import EnumParameterValue

from uipath_langchain.agent.guardrails.attachment_refs import (
_MAX_ATTACHMENTS,
resolve_guardrail_attachments,
)

_UUID = "7f2c1e44-0b3a-4a1e-9d55-2f9a1c3b8e10"


def _judge() -> MagicMock:
guardrail = MagicMock(spec=BuiltInValidatorGuardrail)
guardrail.name = "Example"
guardrail.validator_type = "llm_as_judge"
return guardrail


def _scoped_judge(applies_to: str, parameter_id: str = "appliesTo") -> MagicMock:
"""A judge guardrail carrying the ``appliesTo`` parameter the designer writes."""
guardrail = _judge()
guardrail.validator_parameters = [
EnumParameterValue.model_validate(
{"$parameterType": "enum", "id": parameter_id, "value": applies_to}
)
]
return guardrail


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)}


class TestResolveGuardrailAttachments:
async def test_resolves_text_attachment(self, monkeypatch):
result = await resolve_guardrail_attachments(_registry(), _judge())

assert [r.model_dump(by_alias=True) for r in result] == [
{
"id": _UUID,
"fileName": "a.csv",
"mimeType": "text/csv",
}
]

@pytest.mark.parametrize(
"validator_type", ["pii_detection", "user_prompt_attacks", "harmful_content"]
)
async def test_resolves_for_any_validator(self, monkeypatch, validator_type):
"""The runtime forwards for every guardrail; the backend decides who can use it."""
guardrail = MagicMock(spec=BuiltInValidatorGuardrail)
guardrail.validator_type = validator_type

result = await resolve_guardrail_attachments(_registry(), guardrail)

assert [r.file_name for r in result] == ["a.csv"]

@pytest.mark.parametrize(
"mime", ["application/octet-stream", "application/zip", "video/mp4"]
)
async def test_forwards_any_mime_type(self, monkeypatch, mime):
"""No type filter here: the backend skips (and logs) what it cannot inspect."""
result = await resolve_guardrail_attachments(_registry(mime=mime), _judge())

assert [r.mime_type for r in result] == [mime]

@pytest.mark.parametrize("mime,name", [("", "a.csv"), ("text/csv", "")])
async def test_skips_attachment_missing_name_or_type(self, monkeypatch, mime, name):
"""The validate API requires both; forwarding an empty one would 400 the call."""
result = await resolve_guardrail_attachments(
_registry(mime=mime, name=name), _judge()
)

assert result == []

async def test_skips_attachment_with_non_uuid_id(self, monkeypatch):
"""The validate API requires a GUID; a malformed id must not reach it, and this
module never raises over it either."""
attachment = MagicMock(id="not-a-uuid", full_name="a.csv", mime_type="text/csv")

result = await resolve_guardrail_attachments(
{"not-a-uuid": attachment}, _judge()
)

assert result == []

async def test_caps_attachment_count(self, monkeypatch):
registry = {}
for index in range(10):
attachment_id = str(uuid.uuid4())
registry[attachment_id] = Attachment(
id=uuid.UUID(attachment_id),
full_name=f"{index}.csv",
mime_type="text/csv",
)

result = await resolve_guardrail_attachments(registry, _judge())

assert len(result) == 5

async def test_malformed_entry_neither_raises_nor_consumes_a_slot(
self, monkeypatch
):
"""One bad registry value must not end the run or hide a later valid file."""
registry: dict[str, Any] = {"bad": object()}
for index in range(_MAX_ATTACHMENTS):
attachment_id = str(uuid.uuid4())
registry[attachment_id] = Attachment(
id=uuid.UUID(attachment_id),
full_name=f"{index}.csv",
mime_type="text/csv",
)

result = await resolve_guardrail_attachments(registry, _judge())

assert [a.file_name for a in result] == [
f"{i}.csv" for i in range(_MAX_ATTACHMENTS)
]

async def test_returns_empty_for_empty_registry(self, monkeypatch):
assert await resolve_guardrail_attachments({}, _judge()) == []

async def test_truncates_over_long_file_names_to_the_api_ceiling(self, monkeypatch):
"""The validate API rejects names over 260 chars; a 400 there would kill the run."""
long_name = "x" * 300 + ".csv"

result = await resolve_guardrail_attachments(
_registry(name=long_name), _judge()
)

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."""
result = await resolve_guardrail_attachments(
_registry(), _scoped_judge(applies_to)
)

assert result == []

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")
)

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()
guardrail.validator_parameters = 7 # not a list

result = await resolve_guardrail_attachments(_registry(), guardrail)

assert [r.file_name for r in result] == ["a.csv"]
Loading
Loading