Skip to content

feat: add EscalateAction HITL middleware action for guardrails [AL-289]#888

Open
apetraru-uipath wants to merge 10 commits into
mainfrom
feat/langchain_guardrails_escalations
Open

feat: add EscalateAction HITL middleware action for guardrails [AL-289]#888
apetraru-uipath wants to merge 10 commits into
mainfrom
feat/langchain_guardrails_escalations

Conversation

@apetraru-uipath

Copy link
Copy Markdown
Contributor

What changed?

Adds human-in-the-loop guardrail escalation to the LangChain middleware path, which previously only supported LogAction/BlockAction.

  • New EscalateAction(GuardrailAction) (uipath_langchain.guardrails): on a guardrail violation it escalates the flagged content to a UiPath Action App (e.g. the Guardrail Escalation Action App) via the documented HITL primitive interrupt(CreateEscalation(...)). It maps guardrail context onto the action app's input schema (GuardrailName, GuardrailDescription, GuardrailResult, Inputs, plus legacy Tool* aliases — mirroring the factory-path EscalateAction) and applies the reviewer's decision back: Approve returns the edited content (ReviewedInputs) for the middleware to substitute, Reject raises GuardrailBlockException.
  • Middleware no longer swallows interrupts (middlewares/_base.py): the built-in guardrail middlewares invoked the action inside a broad except Exception:, which caught interrupt()'s GraphInterrupt (an Exception subclass) and merely logged it. We now re-raise GraphBubbleUp before that handler in _check_messages and both _run_tool_guardrail (PRE/POST) paths, so an escalation action can suspend/resume the run durably. Purely additive — no existing behavior changes.
  • Sample: the joke-agent PII guardrail now escalates via action=EscalateAction(...) (replacing the prior log-only action), with README docs and env-configurable app name/folder.

Note: the package version bump (pyproject.toml0.11.13) and uv.lock / sample dependency pin land in a follow-up commit on this branch (currently pointing at local editable dev paths).

How has this been tested?

  • pytest tests/guardrails tests/agent/guardrails332 passed (the shared _base.py change is non-regressive).
  • ruff check, ruff format --check, mypy → clean on all changed files.
  • Live end-to-end run: a PII-containing topic triggers EscalateActioninterrupt(CreateEscalation(...)) propagates → the CreateEscalation task is created in the deployed action app and the run suspends successfully. A non-PII topic completes normally (no escalation).

Are there any breaking changes?

  • None

Add EscalateAction(GuardrailAction) so coded-agent guardrail middlewares can
escalate violations to a UiPath Action App via the documented HITL primitive
interrupt(CreateEscalation(...)). It maps guardrail context onto the action
app's input schema and applies the reviewer's Approve/Reject decision back.

Stop the built-in guardrail middlewares from swallowing LangGraph control-flow
signals: re-raise GraphBubbleUp before the broad except Exception in
_check_messages and _run_tool_guardrail, so interrupt() from an action suspends
the run instead of being logged.

Wire the joke-agent PII guardrail to escalate via EscalateAction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 4, 2026 13:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new human-in-the-loop escalation action for the guardrails middleware path, and updates middleware exception handling so LangGraph control-flow signals (e.g., interrupt(...)) can suspend/resume runs instead of being swallowed by broad exception handlers.

Changes:

  • Introduce EscalateAction (middleware GuardrailAction) that escalates guardrail violations via interrupt(CreateEscalation(...)) and applies reviewer outcomes (Approve substitutes ReviewedInputs, Reject blocks).
  • Update built-in guardrail middleware base to re-raise GraphBubbleUp so HITL interrupts propagate correctly.
  • Update the joke-agent sample to demonstrate PII escalation and document configuration/env vars.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/uipath_langchain/guardrails/middlewares/_base.py Ensures LangGraph control-flow exceptions (GraphBubbleUp) bubble up instead of being logged/swallowed.
src/uipath_langchain/guardrails/escalate_action.py Adds middleware-compatible EscalateAction to create HITL escalation tasks and handle review outcomes.
src/uipath_langchain/guardrails/actions.py Re-exports EscalateAction alongside existing LogAction/BlockAction.
src/uipath_langchain/guardrails/__init__.py Exposes EscalateAction at the package level.
samples/joke-agent/README.md Documents HITL guardrail escalation usage, behavior, and configuration variables.
samples/joke-agent/graph.py Updates sample guardrails to use EscalateAction and adds env-configurable app parameters.

Comment thread src/uipath_langchain/guardrails/escalate_action.py
Comment on lines +117 to +121
if outcome.lower() == "approve":
reviewed = response.get("ReviewedInputs")
if not reviewed:
return None
return _coerce_reviewed(reviewed, data_is_dict)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This falsy check is intentional and mirrors the factory-path EscalateAction (which also treats an empty reviewed value as 'no change'), so approving without editing the field never wipes the input. Clarified in 6c98e56 via the docstring, an inline comment, and the README so the wording matches the behavior (absent/empty ReviewedInputs → keep original).

Comment thread samples/joke-agent/README.md
apetraru-uipath and others added 4 commits June 4, 2026 16:56
…AL-289]

UiPathPIIDetectionMiddleware previously registered both before_* and after_*
hooks for AGENT/LLM scopes regardless of stage (stage only gated TOOL). An
escalation action would therefore fire twice per run when PII persisted in the
conversation. Gate AGENT/LLM hook registration by stage: PRE registers only the
before_* checkpoint, POST only after_*, PRE_AND_POST both (unchanged default).

Set the joke-agent PII escalation guardrail to stage=PRE so it escalates once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…time context [AL-289]

Publish the guardrail context (scope / execution stage / guarded component)
around each middleware action invocation via a ContextVar, so context-aware
actions read it at runtime instead of requiring it to be hardcoded:

- new _action_context.py (GuardrailActionContext + ContextVar + component label)
- _base.py: _handle_validation_result sets the context; _check_messages and
  _run_tool_guardrail thread scope/stage/component through
- pii_detection.py: the AGENT/LLM hooks pass their real scope + PRE/POST stage

EscalateAction now derives Component/Tool/ExecutionStage from that context
(dropping the component/execution_stage params) and JSON-encodes the flagged
payload so the action app can parse the component-input field. TenantName falls
back to the base-URL tenant; AgentTrace is built from UiPathConfig.

The joke-agent PII escalation no longer hardcodes component/execution_stage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…xt + HITL) [AL-289]

Adds tests for the middleware guardrail-escalation path:

- test_escalate_action.py: EscalateAction unit tests — trigger/no-trigger,
  interrupt(CreateEscalation) payload, JSON-encoded Inputs/ToolInputs,
  context-derived Component/ExecutionStage, TenantName (config + URL fallback),
  AgentTrace, Approve/modify/Reject handling, and the pure helpers.
- test_action_context.py: component_label + ContextVar round-trip.
- test_action_context_publishing.py: the mixin publishes scope/stage/component
  to the action and resets it; GraphInterrupt is re-raised (not swallowed).
- test_hook_wiring.py: PII stage gating for AGENT/LLM scopes.
- test_guardrails_in_langgraph.py: middleware-only escalation scenario via
  create_agent + MemorySaver exercising the real interrupt -> resume cycle
  (suspend payload, Approve-with-modify, Reject). Decorator-flavor parity is a
  documented follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Drop the 'PII/' prefix from the escalation log message; EscalateAction is
  generic, so log a 'violation detected' rather than implying PII.
- Clarify that an absent/empty ReviewedInputs keeps the original input (the
  intentional, factory-path-consistent behavior) in the docstring + an inline
  comment + the sample README.
- Make all EscalateAction example app name/folder consistent
  (Guardrail.Escalation.Action.App.2 / Shared) across the docstring and README
  so they match the documented/sample defaults (no copy/paste confusion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@apetraru-uipath apetraru-uipath force-pushed the feat/langchain_guardrails_escalations branch from aa2dc08 to 6c98e56 Compare June 4, 2026 16:32
apetraru-uipath and others added 2 commits June 5, 2026 13:19
…L-289]

Add a recipient: TaskRecipient field to the middleware EscalateAction, passed
through to CreateEscalation(recipient=...) alongside assignee. This exposes the
escalation target types the HITL primitive supports — UserId, GroupId,
UserEmail, GroupName — using the already-resolved TaskRecipient (no resolver,
no asset/argument handling, which belong to the factory/agent.json path).

Tests cover pass-through for each TaskRecipientType, the None default, and
assignee+recipient coexisting; README documents the typed-target option.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Bump uipath-langchain to 0.11.13 (+ uv.lock and joke-agent min pin) for the
  EscalateAction middleware feature.
- Reduce _check_messages cognitive complexity (18 -> well under 15) by extracting
  the message-substitution loop into _apply_text_modification (SonarCloud).
- Make test_escalate_action.py order-independent: patch via the captured module
  object (patch.object) instead of a sys.modules string path, so it survives
  test_no_circular_imports reloading modules.
- joke-agent escalation reverted to no assignee/recipient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stage=GuardrailExecutionStage.PRE,
action=EscalateAction(
app_name=ESCALATION_APP_NAME,
app_folder_path=ESCALATION_APP_FOLDER,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we also support recipientType, but I don't want to add emails to this sample

to apply guardrail to. Must contain at least one tool.
Can be a mix of strings (tool names) or BaseTool objects.
If TOOL scope is not specified, this parameter is ignored.
stage: Optional execution stage controlling when the guardrail runs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've extended the functionality to allow pre and post for middleware PII

…L-289]

Add an 'Escalation action (human-in-the-loop)' section to the LangChain
guardrails docs: behavior (interrupt(CreateEscalation) → suspend → Approve/
Reject/modify), a complete example with a typed recipient, the app_name/
app_folder_path/assignee/recipient/title parameters, the runtime-derived
Component/ExecutionStage note, an escalate-once tip, and a cross-link to the
HITL primitive doc. Also add EscalateAction to the imports/action options and
correct the GuardrailExecutionStage reference note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread docs/guardrails.md Outdated
- **`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.

The app also receives `GuardrailName`, `GuardrailDescription`, `GuardrailResult`, and the flagged `Inputs`/`ToolInputs`, plus `Component` and `ExecutionStage` — these last two are **derived automatically** from the guardrail's runtime context (scope → component, hook → stage), so you don't configure them on the action.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section is a bit confusing. It looks like GuardrailName can be configured by the developer on the action, which is not true, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it was based on first iteration of the design.... I will update the docs

Comment thread samples/joke-agent/README.md Outdated
)
```

> `stage` now gates AGENT/LLM scopes too (not just TOOL): `PRE` registers only the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section about the stage is a bit confusing too. Is it necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will regenerate this readme file

human acts. ``interrupt()`` is memoized, so replay-on-resume never creates a
duplicate task.
2. On resume, the completed task's outcome drives the result:
- ``Approve`` → return the reviewer-edited content (``ReviewedInputs``) so

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can also be ReviewedOutputs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 18ad20c. EscalateAction is now stage-aware: it reads ReviewedInputs for a PRE (input) escalation and ReviewedOutputs for a POST (output) one (_reviewed_field_name), and the docstring/Lifecycle now documents both.

)

if outcome.lower() == "approve":
reviewed = response.get("ReviewedInputs")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we handle the ReviewedOutputs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 18ad20c. On resume, handle_validation_result picks the reviewed field by execution stage — ReviewedOutputs at POST, ReviewedInputs at PRE — and returns it for the middleware to substitute into the output (absent/empty → keep original, as before). Covered by test_post_approve_reads_reviewed_outputs.

"""
data: dict[str, Any] = {
"GuardrailName": guardrail_name,
"GuardrailDescription": result.reason or "",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guardrail description is different from the result.reason. To be fixed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 18ad20c. GuardrailDescription now comes from the guardrail's static description (the middleware publishes self._guardrail.description via the runtime context), while GuardrailResult keeps result.reason. They're distinct fields now.

"GuardrailName": guardrail_name,
"GuardrailDescription": result.reason or "",
"GuardrailResult": result.reason or "",
"Inputs": content,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about outputs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 18ad20c. The payload is now stage-aware and matches the helix escalation-app schema: PRE fills ToolInputs/Inputs; POST fills ToolOutputs/Outputs with the flagged output and carries the original input in ToolInputs, so the reviewer sees both tool inputs and outputs.

# the middleware publishes (scope → component, hook → stage).
ctx = current_action_context()
if ctx and ctx.component:
data["Component"] = ctx.component

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is the tool name being passed, in case the scope is TOOL?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool name is passed via the published context's component: for TOOL scope the middleware sets component=tool_name in _run_tool_guardrail (both PRE and POST), so ctx.component populates both Tool and Component. Confirmed by a test asserting Tool == "my_tool". No functional change needed — added a clarifying comment.

…pulated [AL-289]

The previous wording implied only Component/ExecutionStage were derived
automatically, suggesting GuardrailName/Description/Result could be configured
on the action. None of the payload fields are developer-configured: list each
field with its actual source (guardrail name, validator reason, flagged inputs,
runtime context, deployment context) to match _build_app_inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread docs/guardrails.md
- **`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.

Beyond the parameters above, the action builds the rest of the review payload **automatically** — none of these are configured on the action:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@valentinabojan do we need to specify that the below fields are set automatically? Or can I drop this section?

… fields [AL-289]

Address review on EscalateAction: it was input/PRE-only and stage-blind.

- escalate_action.py: stage-aware payload — PRE fills ToolInputs/Inputs; POST
  fills ToolOutputs/Outputs and carries the original input in ToolInputs (so the
  app shows both). Resume reads ReviewedInputs at PRE / ReviewedOutputs at POST.
  GuardrailDescription now comes from the guardrail's description (via context),
  distinct from GuardrailResult (the validation reason). Matches the helix app
  schema and the factory-path EscalateAction.
- _action_context.py: add description + input_payload to GuardrailActionContext.
- middlewares/_base.py: publish the guardrail description and, at POST, the
  original input (tool args, or the last human message for message scopes).
- pii_detection / harmful_content / intellectual_property: POST hooks supply the
  original input; harmful_content and IP now also publish scope/stage (parity).
- tests: POST payload/resume + GuardrailDescription coverage.
- docs: docs/guardrails.md + joke-agent README describe the PRE/POST split and
  the ReviewedInputs/ReviewedOutputs distinction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jun 8, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants