From 2c30fbeda690c0b75bdb111ad64641ace4d9642f Mon Sep 17 00:00:00 2001 From: andreizdrali-uipath Date: Thu, 24 Sep 2026 15:01:09 +0300 Subject: [PATCH 1/2] fix(guardrails): resolve tool guardrails on tools named with special characters A custom Tool-scope guardrail with an all-fields rule failed agent startup with INVALID_GUARDRAIL_CONFIG when the tool's name had a space or special character. The selector holds the name as typed ("My Function"), tools are registered sanitized ("My_Function"), and the selector is only sanitized after the rules are converted. Process tools' display_name is the process name, so that fallback did not match either. Compare the sanitized match name with the tool name, keeping the display_name fallback that MCP tools rely on. Fixes AL-610. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- .../agent/guardrails/guardrails_factory.py | 7 +- .../guardrails/test_guardrails_factory.py | 104 ++++++++++++++++++ uv.lock | 2 +- 4 files changed, 112 insertions(+), 3 deletions(-) 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/guardrails_factory.py b/src/uipath_langchain/agent/guardrails/guardrails_factory.py index b4464582a..9420f4930 100644 --- a/src/uipath_langchain/agent/guardrails/guardrails_factory.py +++ b/src/uipath_langchain/agent/guardrails/guardrails_factory.py @@ -44,6 +44,7 @@ LogAction, ) from uipath_langchain.agent.guardrails.utils import _sanitize_selector_tool_names +from uipath_langchain.agent.tools.utils import sanitize_tool_name def _has_schema(tool: BaseTool, attribute_name: str) -> bool: @@ -320,11 +321,15 @@ def _compute_field_sources_for_guardrail( and len(guardrail.selector.match_names) > 0 ): match_name = guardrail.selector.match_names[0] + # The selector holds the name as the author typed it; tools are registered + # under the sanitized name. The selector itself is sanitized only after + # the rules are converted. + sanitized_match_name = sanitize_tool_name(match_name) matching_tool = next( ( t for t in tools - if t.name == match_name + if t.name == sanitized_match_name or ( isinstance(t.metadata, dict) and t.metadata.get("display_name") == match_name diff --git a/tests/agent/guardrails/test_guardrails_factory.py b/tests/agent/guardrails/test_guardrails_factory.py index a2c402c58..8089808bf 100644 --- a/tests/agent/guardrails/test_guardrails_factory.py +++ b/tests/agent/guardrails/test_guardrails_factory.py @@ -22,6 +22,9 @@ AgentGuardrailUnknownAction, AgentNumberOperator, AgentNumberRule, + AgentProcessToolProperties, + AgentProcessToolResourceConfig, + AgentToolType, AgentWordOperator, AgentWordRule, AssetRecipient, @@ -58,6 +61,7 @@ _create_word_rule_func, build_guardrails_with_actions, ) +from uipath_langchain.agent.tools.process_tool import create_process_tool class TestGuardrailsFactory: @@ -1388,3 +1392,103 @@ def test_convert_custom_guardrail_with_empty_rules(self) -> None: assert isinstance(result, DeterministicGuardrail) assert len(result.rules) == 0 + + +class TestToolScopeMatchNames: + """A Tool-scope selector names a tool as the author typed it, not sanitized.""" + + @pytest.mark.parametrize("tool_name", ["My Function", "Send E-mail (v2)!"]) + def test_all_fields_rule_resolves_a_process_tool_named_with_special_characters( + self, tool_name: str + ) -> None: + tool = create_process_tool( + AgentProcessToolResourceConfig( + type=AgentToolType.FUNCTION, + name=tool_name, + description="Test function", + input_schema={ + "type": "object", + "properties": {"amount": {"type": "number"}}, + }, + output_schema={ + "type": "object", + "properties": {"total": {"type": "number"}}, + }, + properties=AgentProcessToolProperties( + process_name="Function_1", + folder_path="solution_folder", + ), + ) + ) + guardrail = AgentCustomGuardrail.model_validate( + { + "$guardrailType": "custom", + "id": "guardrail-1", + "name": "Guardrail 1", + "description": "Test guardrail", + "enabledForEvals": True, + "selector": {"scopes": ["Tool"], "matchNames": [tool_name]}, + "rules": [ + { + "$ruleType": "number", + "fieldSelector": {"$selectorType": "all"}, + "operator": "equals", + "value": 10, + } + ], + "action": {"$actionType": "block", "reason": "blocked"}, + } + ) + + [(converted, _)] = build_guardrails_with_actions([guardrail], [tool]) + + assert isinstance(converted, DeterministicGuardrail) + assert converted.selector is not None + # The tool guardrails subgraph attaches the guardrail by this name. + assert converted.selector.match_names == [tool.name] + [rule] = converted.rules + assert isinstance(rule, NumberRule) + assert isinstance(rule.field_selector, AllFieldsSelector) + assert rule.field_selector.sources == [FieldSource.INPUT, FieldSource.OUTPUT] + + def test_all_fields_rule_resolves_an_mcp_tool_by_its_display_name(self) -> None: + """An MCP tool's name is qualified by its resource; the selector names the + tool's own name, which the tool keeps as its display name.""" + from unittest.mock import Mock + + from pydantic import BaseModel + + class ToolInput(BaseModel): + query: str + + tool = Mock(spec=BaseTool) + tool.name = "mcp-sales_mcp-tool-search_tool" + tool.args_schema = ToolInput + tool.metadata = {"tool_type": "mcp", "display_name": "Search Tool!"} + guardrail = AgentCustomGuardrail.model_validate( + { + "$guardrailType": "custom", + "id": "guardrail-1", + "name": "Guardrail 1", + "description": "Test guardrail", + "enabledForEvals": True, + "selector": {"scopes": ["Tool"], "matchNames": ["Search Tool!"]}, + "rules": [ + { + "$ruleType": "word", + "fieldSelector": {"$selectorType": "all"}, + "operator": "contains", + "value": "forbidden", + } + ], + "action": {"$actionType": "block", "reason": "blocked"}, + } + ) + + [(converted, _)] = build_guardrails_with_actions([guardrail], [tool]) + + assert isinstance(converted, DeterministicGuardrail) + [rule] = converted.rules + assert isinstance(rule, WordRule) + assert isinstance(rule.field_selector, AllFieldsSelector) + assert rule.field_selector.sources == [FieldSource.INPUT] 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" }, From 215bb99d326ca82e25e6f96818d28b435860e603 Mon Sep 17 00:00:00 2001 From: andreizdrali-uipath Date: Fri, 25 Sep 2026 14:29:11 +0300 Subject: [PATCH 2/2] chore(guardrails): drop the comment above the sanitized match name Co-Authored-By: Claude Opus 5.5 --- src/uipath_langchain/agent/guardrails/guardrails_factory.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/uipath_langchain/agent/guardrails/guardrails_factory.py b/src/uipath_langchain/agent/guardrails/guardrails_factory.py index 9420f4930..45dbe0519 100644 --- a/src/uipath_langchain/agent/guardrails/guardrails_factory.py +++ b/src/uipath_langchain/agent/guardrails/guardrails_factory.py @@ -321,9 +321,6 @@ def _compute_field_sources_for_guardrail( and len(guardrail.selector.match_names) > 0 ): match_name = guardrail.selector.match_names[0] - # The selector holds the name as the author typed it; tools are registered - # under the sanitized name. The selector itself is sanitized only after - # the rules are converted. sanitized_match_name = sanitize_tool_name(match_name) matching_tool = next( (