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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
25 changes: 14 additions & 11 deletions src/uipath_langchain/agent/guardrails/attachment_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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 []
Expand All @@ -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 []
Expand Down
75 changes: 46 additions & 29 deletions tests/agent/guardrails/test_attachment_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading