Skip to content
Merged
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
44 changes: 42 additions & 2 deletions docs/guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ agent = create_agent(
```python
from uipath_langchain.guardrails import (
BlockAction,
EscalateAction,
LogAction,
LoggingSeverityLevel,
UiPathDeterministicGuardrailMiddleware,
Expand Down Expand Up @@ -73,7 +74,7 @@ TOOL scope for `UiPathPIIDetectionMiddleware` and `UiPathHarmfulContentMiddlewar
All classes share these common parameters:

- **`name`** (`str`) — display name for this guardrail instance.
- **`action`** — what to do on violation: `LogAction(...)` or `BlockAction(...)`.
- **`action`** — what to do on violation: `LogAction(...)`, `BlockAction(...)`, or `EscalateAction(...)` (escalate to a human — see [Escalation action](#escalation-action-human-in-the-loop)).
- **`scopes`** (`list[GuardrailScope]`) — restrict which hooks are registered. Defaults shown in the table above. Use `GuardrailScope.AGENT`, `GuardrailScope.LLM`, `GuardrailScope.TOOL`.
- **`enabled_for_evals`** (`bool`, default `True`) — set `False` to skip this guardrail when the agent runs in evaluation mode.

Expand Down Expand Up @@ -238,6 +239,45 @@ agent = create_agent(
)
```

### Escalation action (human-in-the-loop)

`EscalateAction` routes a violation to a **human reviewer** instead of logging or blocking it. On a violation it builds the review payload and calls the documented HITL primitive [`interrupt(CreateEscalation(...))`](https://uipath.github.io/uipath-python/langchain/human_in_the_loop/#3-createescalation) — creating a task in a UiPath **Action App** and **suspending the run** until the reviewer responds. On resume:

- **Approve** — if the reviewer edited the content, the edited value is substituted back into the flagged message / tool args / output; otherwise the original is kept. The edit is read from `ReviewedInputs` for a PRE (input) escalation and `ReviewedOutputs` for a POST (output) one.
- **Reject** — raises `GuardrailBlockException`, terminating the run.

```python
from uipath_langchain.guardrails import EscalateAction
from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType

*UiPathPIIDetectionMiddleware(
name="PII escalation",
scopes=[GuardrailScope.AGENT],
stage=GuardrailExecutionStage.PRE, # validate once → escalate once per run
action=EscalateAction(
app_name="Guardrail Escalation Action App",
app_folder_path="Shared",
# route the review task to a specific recipient (user / group / email)
recipient=TaskRecipient(
type=TaskRecipientType.EMAIL, value="reviewer@example.com"
),
),
entities=[PIIDetectionEntity(PIIDetectionEntityType.EMAIL, threshold=0.5)],
),
```

Parameters:

- **`app_name`** (`str`, required) — the published Action App that renders the review task.
- **`app_folder_path`** (`str`) — folder where the app is deployed.
- **`assignee`** (`str`) — the simple username/email to assign the task to.
- **`recipient`** (`TaskRecipient`) — a typed escalation target (shown above); takes precedence over `assignee`. Supports the four `TaskRecipientType` members — `USER_ID`, `GROUP_ID`, `EMAIL` (user email), and `GROUP_NAME`, e.g. `TaskRecipient(type=TaskRecipientType.GROUP_NAME, value="Reviewers")`.
- **`title`** (`str`) — task title; defaults to a message derived from the guardrail name.

> 💡 **Escalate once per run.** On AGENT/LLM scope a guardrail validates both *before* and *after* by default, which can escalate twice. Set `stage=GuardrailExecutionStage.PRE` (or `POST`) so only a single checkpoint is registered.

> ⚠️ **Requires a published Action App.** The target app must exist in the configured folder for the task to be created. Resume is durable — the run suspends on `interrupt()` and resumes when the task is completed. See [Human In The Loop](https://uipath.github.io/uipath-python/langchain/human_in_the_loop/) for the underlying primitive.

### Custom actions

Both the built-in middleware and `UiPathDeterministicGuardrailMiddleware` accept any `GuardrailAction` subclass as the `action` parameter. This lets you implement content sanitisation, redaction, or any other custom response to a violation:
Expand Down Expand Up @@ -413,7 +453,7 @@ Imported from `uipath_langchain.guardrails`.
|---|---|
| `PRE` | Before the call (inspect / block inputs) |
| `POST` | After the call (inspect / transform outputs) |
| `PRE_AND_POST` | Both — used only by `UiPathDeterministicGuardrailMiddleware` |
| `PRE_AND_POST` | Both checkpoints (the default) |

### LoggingSeverityLevel

Expand Down
69 changes: 68 additions & 1 deletion samples/joke-agent/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Joke Agent

A LangGraph agent that generates family-friendly jokes based on a given topic using UiPath's LLM. The agent includes comprehensive guardrails for PII detection, prompt injection prevention, and content validation.
A LangGraph agent that generates family-friendly jokes based on a given topic using UiPath's LLM. The agent includes comprehensive guardrails for PII detection, prompt injection prevention, and content validation — plus a **human-in-the-loop (HITL) escalation** to the *Guardrail Escalation Action App* when PII is detected in the agent's input.

## Requirements

Expand Down Expand Up @@ -52,12 +52,79 @@ The `topic` field should be a string representing the subject for the joke. The
- Custom logging middleware that logs input and output
- Simple, clean architecture following UiPath agent patterns

## Guardrail Escalation (Human-in-the-Loop)

The PII detection guardrail uses **`EscalateAction`** — a first-class guardrail
middleware action, alongside `LogAction` and `BlockAction`. **When PII is detected, the run
escalates to a human reviewer** through the **Guardrail Escalation Action App** and suspends
until the task is completed, reusing the documented UiPath LangChain HITL primitive
`interrupt(CreateEscalation(...))` — the same mechanism the
[`ticket-classification`](../ticket-classification) sample uses.

```python
UiPathPIIDetectionMiddleware(
name="PII escalation guardrail",
scopes=[GuardrailScope.AGENT],
stage=GuardrailExecutionStage.PRE, # validate input once → escalate once per run
action=EscalateAction(
app_name="Guardrail.Escalation.Action.App.2",
app_folder_path="Shared",
),
Comment thread
valentinabojan marked this conversation as resolved.
entities=[PIIDetectionEntity(PIIDetectionEntityType.EMAIL, 0.5)],
)
```

On a violation the run **suspends** with a review task in the action app. The reviewer can edit the
flagged input (or, at a POST check, the output) and **Approve** to resume with the edited value, or
**Reject** to terminate the run with a guardrail-violation error.

### Configuration

The target app is configured via environment variables (defaults shown):

| Variable | Default | Purpose |
|---|---|---|
| `GUARDRAIL_ESCALATION_APP_NAME` | `Guardrail.Escalation.Action.App.2` | Published action app (process) name |
| `GUARDRAIL_ESCALATION_APP_FOLDER` | `Shared` | Folder where the app is deployed |

The **Guardrail Escalation Action App (2)** must be published to the configured folder on
your tenant for the escalation task to be created. The defaults match its deployed
name/folder (find yours with `uip or processes list --all-folders --name Guardrail`).

**Escalation target.** `assignee` is the simple username/email shortcut. For typed targets,
pass `recipient=TaskRecipient(...)` instead — the HITL `CreateEscalation` primitive supports the
four `TaskRecipientType` members: `USER_ID`, `GROUP_ID`, `EMAIL` (user email), and `GROUP_NAME`, e.g.:

```python
from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType

EscalateAction(
app_name="Guardrail.Escalation.Action.App.2",
app_folder_path="Shared",
recipient=TaskRecipient(type=TaskRecipientType.GROUP_NAME, value="Reviewers"),
)
```

### Triggering an escalation

A `"banana"` topic contains no PII, so it completes without escalating. To exercise the
HITL path, use a topic that contains PII, e.g.:

```bash
uv run uipath run agent '{"topic": "a joke that mentions the email john.doe@example.com"}'
```

The run will suspend after creating the review task. Complete the task in Action Center
(choosing **Approve** or **Reject**), then resume the run with `uv run uipath run agent --resume ...`.

## Agent Architecture

The agent is built using LangGraph's `StateGraph` with custom input/output schemas:

- **Input Schema**: `Input` with a `topic` field
- **Output Schema**: `Output` with a `joke` field
- **`joke` node**: runs the guarded `create_agent` and extracts the joke. The guardrail
middleware (including the PII `EscalateAction`) runs inside the agent.
- **LLM**: UiPathChat with model `gpt-4o-2024-08-06` and temperature `0.7`

### Tools
Expand Down
26 changes: 26 additions & 0 deletions samples/joke-agent/bindings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"version": "2.0",
"resources": [
{
"resource": "app",
"key": "Guardrail.Escalation.Action.App.2.Shared",
"value": {
"name": {
"defaultValue": "Guardrail.Escalation.Action.App.2",
"isExpression": false,
"displayName": "App Name"
},
"folderPath": {
"defaultValue": "Shared",
"isExpression": false,
"displayName": "App Folder Path"
}
},
"metadata": {
"ActivityName": "create_async",
"BindingsVersion": "2.2",
"DisplayLabel": "app_name"
}
}
]
}
35 changes: 24 additions & 11 deletions samples/joke-agent/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from uipath_langchain.chat import UiPathChat
from uipath_langchain.guardrails import (
BlockAction,
EscalateAction,
GuardrailExecutionStage,
HarmfulContentEntity,
LogAction,
Expand Down Expand Up @@ -93,13 +94,28 @@ def analyze_joke_syntax(joke: str) -> str:
system_prompt=SYSTEM_PROMPT,
middleware=[
*LoggingMiddleware,
# PII detection on the agent scope. On a violation it escalates to the
# Guardrail Escalation Action App for human review via the documented
# HITL interrupt(CreateEscalation(...)) — the run suspends until a human
# approves (optionally editing the content) or rejects.
*UiPathPIIDetectionMiddleware(
name="My personal PII detector",
scopes=[GuardrailScope.AGENT, GuardrailScope.LLM],
action=LogAction(severity_level=LoggingSeverityLevel.WARNING),
name="PII escalation guardrail",
scopes=[GuardrailScope.AGENT],
# PRE only → validate the input once, so the escalation triggers a
# single time per run (AGENT scope would otherwise check both
# before_agent and after_agent).
stage=GuardrailExecutionStage.PRE,
action=EscalateAction(
# Escalation Action App — declared as a binding in bindings.json
# (resource "app"). Studio/deploy resolves and can override it;
# locally these literal values are used.
app_name="Guardrail.Escalation.Action.App.2",
app_folder_path="Shared",
),
entities=[
PIIDetectionEntity(PIIDetectionEntityType.EMAIL, 0.5),
PIIDetectionEntity(PIIDetectionEntityType.CREDIT_CARD_NUMBER, 0.5),
PIIDetectionEntity(PIIDetectionEntityType.PHONE_NUMBER, 0.5),
],
),
*UiPathPIIDetectionMiddleware(
Expand Down Expand Up @@ -182,26 +198,23 @@ def analyze_joke_syntax(joke: str) -> str:
)


# Wrapper node to convert topic input to messages and call the agent
# Wrapper node to convert topic input to messages and call the agent. The
# guardrail middleware runs inside the agent; when the PII escalation guardrail
# fires, interrupt(CreateEscalation(...)) suspends the run for human review.
async def joke_node(state: Input) -> Output:
"""Convert topic to messages, call agent, and extract joke."""
# Convert topic to messages format
messages = [
HumanMessage(
content=f"Generate a family-friendly joke based on the topic: {state.topic}"
)
]

# Call the agent with messages
result = await agent.ainvoke({"messages": messages})

# Extract the joke from the agent's response
joke = result["messages"][-1].content

return Output(joke=joke)


# Build wrapper graph with custom input/output schemas
# Build wrapper graph with custom input/output schemas. The runtime recompiles
# this with a durable checkpointer, so interrupt()/resume works under `uipath run`.
builder = StateGraph(Input, input_schema=Input, output_schema=Output)
builder.add_node("joke", joke_node)
builder.add_edge(START, "joke")
Expand Down
2 changes: 1 addition & 1 deletion samples/joke-agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description = "Joke generating agent that creates family-friendly jokes based on
authors = [{ name = "John Doe", email = "john.doe@myemail.com" }]
requires-python = ">=3.11"
dependencies = [
"uipath-langchain>=0.11.3, <0.12.0",
"uipath-langchain>=0.11.13, <0.12.0",
"uipath>2.7.0",
]

Expand Down
2 changes: 2 additions & 0 deletions src/uipath_langchain/guardrails/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
)

from ._langchain_adapter import LangChainGuardrailAdapter
from .escalate_action import EscalateAction
from .middlewares import (
UiPathDeterministicGuardrailMiddleware,
UiPathHarmfulContentMiddleware,
Expand Down Expand Up @@ -67,6 +68,7 @@
# Actions
"LogAction",
"BlockAction",
"EscalateAction",
"LoggingSeverityLevel",
# Exception
"GuardrailBlockException",
Expand Down
56 changes: 56 additions & 0 deletions src/uipath_langchain/guardrails/_action_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Runtime context for guardrail actions (scope / stage / component).

A :class:`GuardrailAction`'s ``handle_validation_result(result, data,
guardrail_name)`` signature does not carry the guardrail's scope, execution
stage, or guarded-component label — but the middleware that invokes the action
knows all three. The middleware publishes them here (via a ``ContextVar``) for
the duration of the action call, so actions that need them — e.g.
``EscalateAction``, which maps them onto the escalation app's ``Component`` /
``ExecutionStage`` fields — can read them at runtime instead of requiring the
developer to hardcode them.

The context is set synchronously around each action invocation and reset
afterwards, so it is correct across LangGraph's interrupt/replay too (it is
re-published on every replay).
"""

from __future__ import annotations

from contextvars import ContextVar
from dataclasses import dataclass

from .enums import GuardrailExecutionStage, GuardrailScope


@dataclass(frozen=True)
class GuardrailActionContext:
"""The guardrail context active while an action handles a violation."""

scope: GuardrailScope | None = None
execution_stage: GuardrailExecutionStage | None = None
component: str | None = None
description: str | None = None
input_payload: str | None = None


_action_context: ContextVar[GuardrailActionContext | None] = ContextVar(
"uipath_guardrail_action_context", default=None
)


def current_action_context() -> GuardrailActionContext | None:
"""Return the guardrail context for the in-flight action call, if any."""
return _action_context.get()


def component_label(scope: GuardrailScope | None) -> str | None:
"""Map a guardrail scope to the app's component label (matches the SDK).

TOOL has no static label here — the tool name is supplied separately by the
caller — so this returns ``None`` for TOOL scope.
"""
if scope == GuardrailScope.AGENT:
return "Agent"
if scope == GuardrailScope.LLM:
return "LLM call"
return None
4 changes: 3 additions & 1 deletion src/uipath_langchain/guardrails/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@
LoggingSeverityLevel,
)

__all__ = ["LoggingSeverityLevel", "LogAction", "BlockAction"]
from .escalate_action import EscalateAction

__all__ = ["LoggingSeverityLevel", "LogAction", "BlockAction", "EscalateAction"]
Loading
Loading