diff --git a/pyproject.toml b/pyproject.toml index b9289d0a0..894a84345 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/uipath_langchain/agent/guardrails/attachment_refs.py b/src/uipath_langchain/agent/guardrails/attachment_refs.py new file mode 100644 index 000000000..eace068f1 --- /dev/null +++ b/src/uipath_langchain/agent/guardrails/attachment_refs.py @@ -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( + 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 diff --git a/src/uipath_langchain/agent/guardrails/guardrail_nodes.py b/src/uipath_langchain/agent/guardrails/guardrail_nodes.py index abec16e17..be96eb695 100644 --- a/src/uipath_langchain/agent/guardrails/guardrail_nodes.py +++ b/src/uipath_langchain/agent/guardrails/guardrail_nodes.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import re @@ -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, @@ -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}) + def _evaluate_deterministic_guardrail( state: AgentGuardrailsGraphState, @@ -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( @@ -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 diff --git a/tests/agent/guardrails/test_attachment_refs.py b/tests/agent/guardrails/test_attachment_refs.py new file mode 100644 index 000000000..738c209d5 --- /dev/null +++ b/tests/agent/guardrails/test_attachment_refs.py @@ -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"] diff --git a/tests/agent/guardrails/test_guardrail_nodes.py b/tests/agent/guardrails/test_guardrail_nodes.py index 64b0c4600..fc030bb85 100644 --- a/tests/agent/guardrails/test_guardrail_nodes.py +++ b/tests/agent/guardrails/test_guardrail_nodes.py @@ -1,6 +1,7 @@ """Tests for guardrail node creation and routing.""" import json +import uuid from unittest.mock import MagicMock import pytest @@ -32,10 +33,14 @@ def __init__(self, result): self._result = result self.last_text = None self.last_guardrail = None + self.last_attachments = None + self.call_count = 0 - def evaluate_guardrail(self, text, guardrail): + def evaluate_guardrail(self, text, guardrail, *, attachments=None): + self.call_count += 1 self.last_text = text self.last_guardrail = guardrail + self.last_attachments = attachments return self._result @@ -558,16 +563,34 @@ async def test_evaluate_builtin_guardrail(self, monkeypatch): ) guardrail = MagicMock(spec=BuiltInValidatorGuardrail) - state = AgentGuardrailsGraphState(messages=[HumanMessage("test message")]) - def payload_generator(s): - return "generated payload" - - result = _evaluate_builtin_guardrail(state, guardrail, payload_generator) + result = await _evaluate_builtin_guardrail(guardrail, "generated payload") assert result.result == GuardrailValidationResultType.PASSED assert fake.guardrails.last_text == "generated payload" assert fake.guardrails.last_guardrail is guardrail + assert fake.guardrails.last_attachments is None + + @pytest.mark.asyncio + async def test_evaluate_builtin_guardrail_forwards_attachments(self, monkeypatch): + """Attachment references reach the SDK call.""" + from uipath.platform.guardrails import GuardrailAttachment + + from uipath_langchain.agent.guardrails.guardrail_nodes import ( + _evaluate_builtin_guardrail, + ) + + fake = _patch_uipath(monkeypatch) + guardrail = MagicMock(spec=BuiltInValidatorGuardrail) + attachment = GuardrailAttachment( + id="7f2c1e44-0b3a-4a1e-9d55-2f9a1c3b8e10", + file_name="a.csv", + mime_type="text/csv", + ) + + await _evaluate_builtin_guardrail(guardrail, "payload", [attachment]) + + assert fake.guardrails.last_attachments == [attachment] def test_create_validation_command_success(self): """Test validation command creation for successful validation.""" @@ -977,3 +1000,256 @@ async def test_tool_guardrail_payload_populated_post_execution(self, monkeypatch assert metadata is not None assert metadata["payload"]["output"] == "tool output data" assert metadata["payload"]["input"] is None + + +class TestGuardrailNodeAttachments: + """Agent- and LLM-scope nodes forward the run's job attachments to the judge.""" + + _UUID = "7f2c1e44-0b3a-4a1e-9d55-2f9a1c3b8e10" + + @staticmethod + def _judge_guardrail() -> MagicMock: + guardrail = MagicMock(spec=BuiltInValidatorGuardrail) + guardrail.name = "Example" + guardrail.validator_type = "llm_as_judge" + return guardrail + + @staticmethod + def _patch_resolver(monkeypatch, attachments): + from unittest.mock import AsyncMock + + monkeypatch.setattr( + "uipath_langchain.agent.guardrails.guardrail_nodes.resolve_guardrail_attachments", + AsyncMock(return_value=attachments), + ) + + def _state_with_attachment(self): + from uipath.platform.attachments import Attachment + + return AgentGuardrailsGraphState( + messages=[HumanMessage("payload")], + inner_state=InnerAgentGuardrailsGraphState( + job_attachments={ + self._UUID: Attachment( + id=uuid.UUID(self._UUID), + full_name="Tickets.csv", + mime_type="text/csv", + ) + } + ), + ) + + @pytest.mark.asyncio + async def test_agent_init_node_forwards_resolved_attachments(self, monkeypatch): + """An Agent-scope PRE guardrail sees the file supplied as agent input.""" + from uipath.platform.guardrails import GuardrailAttachment + + fake = _patch_uipath(monkeypatch, reason="ok") + attachment = GuardrailAttachment( + id=self._UUID, + file_name="Tickets.csv", + mime_type="text/csv", + ) + self._patch_resolver(monkeypatch, [attachment]) + + _, node = create_agent_init_guardrail_node( + guardrail=self._judge_guardrail(), + execution_stage=ExecutionStage.PRE_EXECUTION, + success_node="ok", + failure_node="nope", + ) + + cmd = await node(self._state_with_attachment()) + + assert cmd.goto == "ok" + assert fake.guardrails.last_attachments == [attachment] + + @pytest.mark.asyncio + async def test_llm_node_forwards_resolved_attachments(self, monkeypatch): + from uipath.platform.guardrails import GuardrailAttachment + + fake = _patch_uipath(monkeypatch, reason="ok") + attachment = GuardrailAttachment( + id=self._UUID, + file_name="Tickets.csv", + mime_type="text/csv", + ) + self._patch_resolver(monkeypatch, [attachment]) + + _, node = create_llm_guardrail_node( + guardrail=self._judge_guardrail(), + execution_stage=ExecutionStage.PRE_EXECUTION, + success_node="ok", + failure_node="nope", + ) + + await node(self._state_with_attachment()) + + assert fake.guardrails.last_attachments == [attachment] + + @pytest.mark.asyncio + async def test_tool_scope_node_never_resolves_attachments(self, monkeypatch): + """Tool scope is excluded by product decision: a tool-scope judge would ship file + contents on every tool call.""" + from unittest.mock import AsyncMock + + fake = _patch_uipath(monkeypatch, reason="ok") + resolver = AsyncMock(return_value=[]) + monkeypatch.setattr( + "uipath_langchain.agent.guardrails.guardrail_nodes.resolve_guardrail_attachments", + resolver, + ) + + _, node = create_tool_guardrail_node( + guardrail=self._judge_guardrail(), + execution_stage=ExecutionStage.PRE_EXECUTION, + success_node="ok", + failure_node="nope", + tool_name="my_tool", + ) + state = AgentGuardrailsGraphState( + messages=[ + AIMessage( + content="", + tool_calls=[{"name": "my_tool", "args": {"q": 1}, "id": "c1"}], + ) + ], + inner_state=InnerAgentGuardrailsGraphState( + job_attachments=self._state_with_attachment().inner_state.job_attachments + ), + ) + await node(state) + + resolver.assert_not_awaited() + assert fake.guardrails.last_attachments == [] + + @pytest.mark.asyncio + async def test_attachment_rejection_falls_back_to_text_only(self, monkeypatch): + """A 400 on a request that carried attachments must not kill the run: the backend + rejected the file references, so evaluate the text payload alone.""" + import httpx + from uipath.platform.errors import EnrichedException + from uipath.platform.guardrails import GuardrailAttachment + + calls: list[list[GuardrailAttachment] | None] = [] + response = httpx.Response( + 400, request=httpx.Request("POST", "https://x/validate"), text="bad url" + ) + rejection = EnrichedException( + httpx.HTTPStatusError("400", request=response.request, response=response) + ) + + class FlakyGuardrails: + def evaluate_guardrail(self, text, guardrail, *, attachments=None): + calls.append(attachments) + if attachments: + raise rejection + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, reason="ok" + ) + + class FlakyUiPath: + guardrails = FlakyGuardrails() + + monkeypatch.setattr( + "uipath_langchain.agent.guardrails.guardrail_nodes.UiPath", + lambda: FlakyUiPath(), + ) + attachment = GuardrailAttachment( + id=self._UUID, + file_name="a.csv", + mime_type="text/csv", + ) + self._patch_resolver(monkeypatch, [attachment]) + + _, node = create_agent_init_guardrail_node( + guardrail=self._judge_guardrail(), + execution_stage=ExecutionStage.PRE_EXECUTION, + success_node="ok", + failure_node="nope", + ) + cmd = await node(self._state_with_attachment()) + + assert cmd.goto == "ok" + assert calls == [[attachment], None] + + @pytest.mark.asyncio + async def test_non_attachment_400_still_propagates(self, monkeypatch): + """Only an attachment-caused 400 is absorbed; a 400 without attachments is real.""" + import httpx + from uipath.platform.errors import EnrichedException + + response = httpx.Response( + 400, request=httpx.Request("POST", "https://x/validate"), text="bad" + ) + rejection = EnrichedException( + httpx.HTTPStatusError("400", request=response.request, response=response) + ) + + class FailingGuardrails: + def evaluate_guardrail(self, text, guardrail, *, attachments=None): + raise rejection + + class FailingUiPath: + guardrails = FailingGuardrails() + + monkeypatch.setattr( + "uipath_langchain.agent.guardrails.guardrail_nodes.UiPath", + lambda: FailingUiPath(), + ) + self._patch_resolver(monkeypatch, []) + + _, node = create_agent_init_guardrail_node( + guardrail=self._judge_guardrail(), + execution_stage=ExecutionStage.PRE_EXECUTION, + success_node="ok", + failure_node="nope", + ) + + with pytest.raises(EnrichedException): + await node(AgentGuardrailsGraphState(messages=[HumanMessage("payload")])) + + @pytest.mark.asyncio + async def test_payload_generator_runs_once_per_evaluation(self, monkeypatch): + """Regression guard: the generator used to run twice — once for observability + metadata and once inside the evaluator — which would double every resolution.""" + calls = [] + _patch_uipath(monkeypatch) + self._patch_resolver(monkeypatch, []) + + def counting_get_message_content(msg): + calls.append(1) + return "payload" + + monkeypatch.setattr( + "uipath_langchain.agent.guardrails.guardrail_nodes.get_message_content", + counting_get_message_content, + ) + + _, node = create_agent_init_guardrail_node( + guardrail=self._judge_guardrail(), + execution_stage=ExecutionStage.PRE_EXECUTION, + success_node="ok", + failure_node="nope", + ) + await node(AgentGuardrailsGraphState(messages=[HumanMessage("payload")])) + + assert len(calls) == 1 + + @pytest.mark.asyncio + async def test_node_still_passes_when_no_attachment_resolved(self, monkeypatch): + """The low-code node is fail-closed, so resolution must absorb its own errors.""" + fake = _patch_uipath(monkeypatch, reason="ok") + self._patch_resolver(monkeypatch, []) + + _, node = create_agent_init_guardrail_node( + guardrail=self._judge_guardrail(), + execution_stage=ExecutionStage.PRE_EXECUTION, + success_node="ok", + failure_node="nope", + ) + + cmd = await node(self._state_with_attachment()) + + assert cmd.goto == "ok" + assert fake.guardrails.last_attachments == [] diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 9d121f5e5..4108522d9 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -21,7 +21,7 @@ def mock_env_vars(): def mock_guardrails_service(): """Mock the guardrails service to avoid HTTP errors in tests.""" - def mock_evaluate_guardrail(text, guardrail): + def mock_evaluate_guardrail(text, guardrail, *, attachments=None): """Mock guardrail evaluation - always passes validation.""" return GuardrailValidationResult( result=GuardrailValidationResultType.PASSED, diff --git a/tests/cli/test_agent_with_guardrails.py b/tests/cli/test_agent_with_guardrails.py index b1e08fc4b..b0c8dc66d 100644 --- a/tests/cli/test_agent_with_guardrails.py +++ b/tests/cli/test_agent_with_guardrails.py @@ -282,7 +282,7 @@ async def mock_llm_invoke(*args, **kwargs): ) # Mock the guardrails service to detect PII and trigger blocking - def mock_evaluate_guardrail(text, guardrail): + def mock_evaluate_guardrail(text, guardrail, *, attachments=None): """Mock guardrail evaluation that detects PII.""" # Only the Agent-level "PII detection guardrail" should fail # Other PII guardrails (like LLM PII escalation) should pass in this test @@ -424,7 +424,7 @@ async def test_prompt_injection_guardrail_triggered( f.write(joke_agent_langgraph_json) # Mock the guardrails service - prompt injection guardrail should fail - def mock_evaluate_guardrail(text, guardrail): + def mock_evaluate_guardrail(text, guardrail, *, attachments=None): """Mock guardrail evaluation - prompt injection fails, others pass.""" # Prompt injection guardrail should detect and block if guardrail.name == "Prompt injection guardrail": @@ -868,7 +868,7 @@ async def test_tool_pii_guardrail_triggered( f.write(joke_agent_langgraph_json) # Mock the guardrails service - PII guardrail at tool level should detect email - def mock_evaluate_guardrail(text, guardrail): + def mock_evaluate_guardrail(text, guardrail, *, attachments=None): """Mock guardrail evaluation that detects PII in tool input.""" # Tool-level PII guardrail should detect email addresses if ( @@ -1029,7 +1029,7 @@ async def test_llm_pii_escalation_guardrail_hitl( f.write(joke_agent_langgraph_json) # Mock the guardrails service - PII guardrail at LLM level should detect PII - def mock_evaluate_guardrail(text, guardrail): + def mock_evaluate_guardrail(text, guardrail, *, attachments=None): """Mock guardrail evaluation that detects PII in LLM output.""" # LLM-level PII escalation guardrail should detect email addresses if ( @@ -1266,7 +1266,7 @@ async def test_llm_pii_escalation_guardrail_rejected( f.write(joke_agent_langgraph_json) # Mock the guardrails service - PII guardrail at LLM level should detect PII - def mock_evaluate_guardrail(text, guardrail): + def mock_evaluate_guardrail(text, guardrail, *, attachments=None): """Mock guardrail evaluation that detects PII in LLM output.""" # LLM-level PII escalation guardrail should detect email addresses if ( diff --git a/uv.lock b/uv.lock index 76b3e0e32..7d43c0266 100644 --- a/uv.lock +++ b/uv.lock @@ -4818,7 +4818,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.18.8" +version = "0.18.9" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4923,7 +4923,7 @@ requires-dist = [ { name = "uipath-langchain-client", extras = ["openai"], specifier = ">=1.20.0,<1.21.0" }, { name = "uipath-langchain-client", extras = ["vertexai"], marker = "extra == 'vertex'", specifier = ">=1.20.0,<1.21.0" }, { name = "uipath-llm-client", specifier = ">=1.20.0,<1.21.0" }, - { name = "uipath-platform", specifier = ">=0.2.30,<0.3.0" }, + { name = "uipath-platform", specifier = ">=0.2.31,<0.3.0" }, { name = "uipath-runtime", specifier = ">=0.13.0,<0.14.0" }, ] provides-extras = ["anthropic", "vertex", "bedrock", "fireworks", "code-interpreter", "all"] @@ -5014,7 +5014,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.30" +version = "0.2.31" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -5025,9 +5025,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/3c/bc878ef471c9921391034a25a25bc3b3422cd3fb642809ff5c2b7499a1e9/uipath_platform-0.2.30.tar.gz", hash = "sha256:ae4281ddaed0aaa89bc1a73dc3bcf7c144ac8037dfbb1f2f41b0651cfd1d21f0", size = 452126, upload-time = "2026-09-15T21:01:30.229Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/fa/087feadb32c9a8b82ee7398cfe4eb1b2d60335f4ca6e9f78b8629dc290a7/uipath_platform-0.2.31.tar.gz", hash = "sha256:2d9f7904006a0b68cca2535df5172d905ad8e19e453eeafbfc20def0d149947d", size = 454031, upload-time = "2026-09-17T07:34:58.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/1f/84e19f263e62d17c3ad24e8c7971df1c558412ee2d664cc973d8b9b5af24/uipath_platform-0.2.30-py3-none-any.whl", hash = "sha256:d6b7a16a6fbf65bb4f4a1ab95d461e4e6e5daf7e27f6257624945e74dd8e9d8b", size = 294049, upload-time = "2026-09-15T21:01:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/54/e4/45b8f27c012ddfd03076c3f07c4c248ccc59ce19ec7e9c5141c5aee3c3a0/uipath_platform-0.2.31-py3-none-any.whl", hash = "sha256:9e88470bcbb2c31dcdf23dc4e2152f73220ce29c7db7495828d10cb2c9b29814", size = 295171, upload-time = "2026-09-17T07:34:56.461Z" }, ] [[package]]