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" },