feat: add EscalateAction HITL middleware action for guardrails [AL-289]#888
feat: add EscalateAction HITL middleware action for guardrails [AL-289]#888apetraru-uipath wants to merge 10 commits into
Conversation
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>
There was a problem hiding this comment.
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(middlewareGuardrailAction) that escalates guardrail violations viainterrupt(CreateEscalation(...))and applies reviewer outcomes (ApprovesubstitutesReviewedInputs,Rejectblocks). - Update built-in guardrail middleware base to re-raise
GraphBubbleUpso HITL interrupts propagate correctly. - Update the
joke-agentsample 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. |
| if outcome.lower() == "approve": | ||
| reviewed = response.get("ReviewedInputs") | ||
| if not reviewed: | ||
| return None | ||
| return _coerce_reviewed(reviewed, data_is_dict) |
There was a problem hiding this comment.
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).
…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>
aa2dc08 to
6c98e56
Compare
…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, |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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>
| - **`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. |
There was a problem hiding this comment.
This section is a bit confusing. It looks like GuardrailName can be configured by the developer on the action, which is not true, right?
There was a problem hiding this comment.
Yes, it was based on first iteration of the design.... I will update the docs
| ) | ||
| ``` | ||
|
|
||
| > `stage` now gates AGENT/LLM scopes too (not just TOOL): `PRE` registers only the |
There was a problem hiding this comment.
This section about the stage is a bit confusing too. Is it necessary?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
It can also be ReviewedOutputs
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
How do we handle the ReviewedOutputs?
There was a problem hiding this comment.
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 "", |
There was a problem hiding this comment.
Guardrail description is different from the result.reason. To be fixed
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Where is the tool name being passed, in case the scope is TOOL?
There was a problem hiding this comment.
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>
| - **`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: |
There was a problem hiding this comment.
@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>
|



What changed?
Adds human-in-the-loop guardrail escalation to the LangChain middleware path, which previously only supported
LogAction/BlockAction.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 primitiveinterrupt(CreateEscalation(...)). It maps guardrail context onto the action app's input schema (GuardrailName,GuardrailDescription,GuardrailResult,Inputs, plus legacyTool*aliases — mirroring the factory-pathEscalateAction) and applies the reviewer's decision back:Approvereturns the edited content (ReviewedInputs) for the middleware to substitute,RejectraisesGuardrailBlockException.middlewares/_base.py): the built-in guardrail middlewares invoked the action inside a broadexcept Exception:, which caughtinterrupt()'sGraphInterrupt(anExceptionsubclass) and merely logged it. We now re-raiseGraphBubbleUpbefore that handler in_check_messagesand both_run_tool_guardrail(PRE/POST) paths, so an escalation action can suspend/resume the run durably. Purely additive — no existing behavior changes.joke-agentPII guardrail now escalates viaaction=EscalateAction(...)(replacing the prior log-only action), with README docs and env-configurable app name/folder.How has this been tested?
pytest tests/guardrails tests/agent/guardrails→ 332 passed (the shared_base.pychange is non-regressive).ruff check,ruff format --check,mypy→ clean on all changed files.EscalateAction→interrupt(CreateEscalation(...))propagates → theCreateEscalationtask 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?