Skip to content
Open
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 packages/uipath/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath"
version = "2.14.12"
version = "2.14.13"
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
21 changes: 19 additions & 2 deletions packages/uipath/src/uipath/agent/models/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,23 @@ def normalizer(v: Any) -> Any:
return BeforeValidator(normalizer)


# solution_folder placeholder needs to be converted to None so that inline guardrail escalations can be resolved just-in-time
_SOLUTION_LOCAL_FOLDER_PLACEHOLDERS = frozenset({"solution_folder", ".", ""})


def _normalize_solution_local_folder(value: Any) -> Any:
"""Map a solution-local folder placeholder onto ``None``."""
if isinstance(value, str) and value.strip() in _SOLUTION_LOCAL_FOLDER_PLACEHOLDERS:
return None
return value


SolutionLocalFolder = Annotated[
Optional[str], BeforeValidator(_normalize_solution_local_folder)
]
"""A folder field that may carry the folder name or solution-folder placeholder which is parsed as ``None``."""


class CaseInsensitiveEnum(Enum):
"""Base class for case-insensitive enums."""

Expand Down Expand Up @@ -830,7 +847,7 @@ class AgentEscalationChannelProperties(BaseEscalationChannelProperties):

app_name: str | None = Field(default=None, alias="appName")
app_version: int = Field(..., alias="appVersion")
folder_name: Optional[str] = Field(None, alias="folderName")
folder_name: SolutionLocalFolder = Field(None, alias="folderName")
resource_key: str | None = Field(default=None, alias="resourceKey")


Expand Down Expand Up @@ -1216,7 +1233,7 @@ class AgentGuardrailEscalateActionApp(BaseModel):
version: int
name: str
folder_id: Optional[str] = Field(None, alias="folderId")
folder_name: str = Field(alias="folderName")
folder_name: SolutionLocalFolder = Field(None, alias="folderName")
app_process_key: Optional[str] = Field(None, alias="appProcessKey")
runtime: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, extra="allow")
Expand Down
70 changes: 69 additions & 1 deletion packages/uipath/tests/agent/models/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,7 @@ def test_agent_config_loads_guardrails(self):
assert escalate_action.action_type == "escalate"
assert escalate_action.app.id == "cf4cb73d-7310-49b1-9a9e-e7653dad7f4e"
assert escalate_action.app.name == "-Guardrail Form"
assert escalate_action.app.folder_name == "solution_folder"
assert escalate_action.app.folder_name is None
assert escalate_action.recipient.type == AgentEscalationRecipientType.USER_ID
assert escalate_action.recipient.value == "5f872639-fc71-4a50-b17d-f68eb357b436"
assert escalate_action.recipient.display_name == "User Name"
Expand Down Expand Up @@ -4778,3 +4778,71 @@ def test_native_bag_survives_verbatim_by_alias(self):
self._agent_settings(modelSettings=native)
)
assert settings.model_dump(by_alias=True)["modelSettings"] == native


class TestSolutionLocalFolderNormalization:
"""A solution-local resource has no real folder; Studio Web stores a placeholder.

Forwarding that placeholder as a folder makes Action Center resolve an app in a
folder that does not exist, so it is normalized to None at parse time.
"""

@staticmethod
def _channel(folder_name: Any) -> dict[str, Any]:
return {
"name": "Channel",
"description": "",
"inputSchema": {"type": "object", "properties": {}},
"type": "actionCenter",
"recipients": [],
"properties": {
"appName": "SimpleApprovalApp",
"appVersion": 1,
"folderName": folder_name,
},
}

@staticmethod
def _guardrail_app(folder_name: Any) -> dict[str, Any]:
return {
"$actionType": "escalate",
"app": {
"id": "812e9239-4cb9-4bd9-9479-bf4f35731e8f",
"version": 1,
"name": "Guardrail Escalation Action App 1",
"folderId": "d8fa328c-cf2e-fae9-2818-da090419aba3",
"folderName": folder_name,
},
"recipient": {"type": 1, "value": "user-id", "displayName": "user"},
}

@pytest.mark.parametrize("placeholder", ["solution_folder", "", ".", " "])
def test_escalation_channel_placeholder_becomes_none(self, placeholder: str):
channel = TypeAdapter(AgentEscalationChannel).validate_python(
self._channel(placeholder)
)
assert channel.properties.folder_name is None

@pytest.mark.parametrize("placeholder", ["solution_folder", "", ".", " "])
def test_guardrail_escalate_app_placeholder_becomes_none(self, placeholder: str):
action = AgentGuardrailEscalateAction.model_validate(
self._guardrail_app(placeholder)
)
assert action.app.folder_name is None

def test_guardrail_escalate_app_folder_name_is_optional(self):
"""Definitions written before the placeholder was dropped omit it entirely."""
payload = self._guardrail_app("solution_folder")
del payload["app"]["folderName"]
action = AgentGuardrailEscalateAction.model_validate(payload)
assert action.app.folder_name is None

def test_real_folder_survives(self):
channel = TypeAdapter(AgentEscalationChannel).validate_python(
self._channel("Shared/Apps")
)
action = AgentGuardrailEscalateAction.model_validate(
self._guardrail_app("Shared")
)
assert channel.properties.folder_name == "Shared/Apps"
assert action.app.folder_name == "Shared"
2 changes: 1 addition & 1 deletion packages/uipath/uv.lock

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