-
Notifications
You must be signed in to change notification settings - Fork 35
feat(guardrails): send attached file references with llm-as-judge evaluation #1082
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.