Skip to content

Commit 0d28670

Browse files
fix(guardrails): publish input_payload at POST on decorator escalation path [AL-289]
The decorator adapter's _run_action published only scope/stage/component, so a POST escalation left EscalateAction's Inputs empty — the reviewer saw the flagged output but not the original input (unlike the middleware path). Publish input_payload at the tool/LLM/agent POST sites so Inputs carries the original input alongside Outputs. Align the decorator test/docs to the Inputs/Outputs and ReviewedOutputs naming the middleware path already uses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0ddc868 commit 0d28670

4 files changed

Lines changed: 75 additions & 12 deletions

File tree

docs/guardrails.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ def create_my_agent():
446446
agent = create_my_agent()
447447
```
448448

449-
On resume: **Approve** continues (substituting `ReviewedInputs` if the reviewer edited the input, otherwise keeping the original); **Reject** raises `GuardrailBlockException` and terminates the run. The `app_name` / `app_folder_path` / `assignee` / `recipient` / `title` parameters and the auto-derived payload fields behave identically to the [middleware escalation action](#escalation-action-human-in-the-loop) above — refer to it for the full parameter list.
449+
On resume: **Approve** continues, substituting the reviewer's edit if any — read from `ReviewedInputs` for a PRE (input) escalation and `ReviewedOutputs` for a POST (output) one, otherwise keeping the original; **Reject** raises `GuardrailBlockException` and terminates the run. The `app_name` / `app_folder_path` / `assignee` / `recipient` / `title` parameters and the auto-derived payload fields behave identically to the [middleware escalation action](#escalation-action-human-in-the-loop) above — refer to it for the full parameter list.
450450

451451
> 💡 **Scope inference for the payload context.** `Component` / `ExecutionStage` are derived automatically for the adapter-handled LangChain targets — `@tool`, `BaseChatModel` factories, and `create_agent()` factories. On a plain LangGraph node or plain Python function (handled by the core `@guardrail`, which doesn't publish the LangChain runtime context) the escalation still suspends, but those two fields are not populated.
452452

src/uipath_langchain/guardrails/_langchain_adapter.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
The adapter is auto-registered when ``uipath_langchain.guardrails`` is imported.
1111
"""
1212

13+
import json
1314
import logging
1415
from functools import wraps
1516
from typing import Any
@@ -72,20 +73,28 @@ def _run_action(
7273
scope: GuardrailScope | None,
7374
stage: GuardrailExecutionStage | None,
7475
component: str | None,
76+
input_payload: str | None = None,
7577
) -> str | dict[str, Any] | None:
7678
"""Run a guardrail action with its runtime context published.
7779
78-
Publishes the guardrail context (scope / stage / component) on a
79-
``ContextVar`` for the duration of the action call, so context-aware actions
80-
(e.g. ``EscalateAction``) can read it at runtime instead of requiring it to
81-
be hardcoded. Mirrors the middleware path's publishing in ``_base.py``.
80+
Publishes the guardrail context (scope / stage / component / input_payload)
81+
on a ``ContextVar`` for the duration of the action call, so context-aware
82+
actions (e.g. ``EscalateAction``) can read it at runtime instead of requiring
83+
it to be hardcoded. ``input_payload`` carries the original input at POST so
84+
the action can show both input and output. Mirrors the middleware path's
85+
publishing in ``_base.py``.
8286
8387
A :class:`GuardrailBlockException` is converted to an ``AgentRuntimeError``.
8488
Any other exception — notably LangGraph's ``GraphInterrupt`` raised by an
8589
escalation action — propagates unchanged so the run can suspend.
8690
"""
8791
token = _action_context.set(
88-
GuardrailActionContext(scope=scope, execution_stage=stage, component=component)
92+
GuardrailActionContext(
93+
scope=scope,
94+
execution_stage=stage,
95+
component=component,
96+
input_payload=input_payload,
97+
)
8998
)
9099
try:
91100
return action.handle_validation_result(result, data, name)
@@ -130,6 +139,23 @@ def _extract_message_text(msg: BaseMessage) -> str:
130139
return ""
131140

132141

142+
def _input_payload_from_messages(
143+
messages: list[BaseMessage] | None,
144+
) -> str | None:
145+
"""JSON-encode the last HumanMessage's text as the original-input payload.
146+
147+
Used at POST so the escalation action can show the original input alongside
148+
the flagged output. Returns ``None`` when there is no usable human input.
149+
"""
150+
if not messages:
151+
return None
152+
msg = _get_last_human_message(messages)
153+
if msg is None:
154+
return None
155+
text = _extract_message_text(msg)
156+
return json.dumps(text) if text else None
157+
158+
133159
def _apply_message_text_modification(msg: BaseMessage, modified: str) -> None:
134160
"""Apply a modified text string back to a message in-place."""
135161
if isinstance(msg.content, str):
@@ -238,6 +264,7 @@ def _apply_post(tool_input: Any, raw_result: Any) -> Any:
238264
scope=GuardrailScope.TOOL,
239265
stage=GuardrailExecutionStage.POST,
240266
component=tool.name,
267+
input_payload=json.dumps(input_data),
241268
)
242269
if modified is not None:
243270
if isinstance(raw_result, (ToolMessage, Command)):
@@ -315,6 +342,7 @@ def _apply_llm_post(
315342
evaluator: _EvaluatorFn,
316343
action: GuardrailAction,
317344
name: str,
345+
input_messages: list[BaseMessage] | None = None,
318346
) -> None:
319347
"""Evaluate the AIMessage content in-place (POST stage, LLM scope)."""
320348
if not isinstance(response.content, str) or not response.content:
@@ -333,6 +361,7 @@ def _apply_llm_post(
333361
scope=GuardrailScope.LLM,
334362
stage=GuardrailExecutionStage.POST,
335363
component=component_label(GuardrailScope.LLM),
364+
input_payload=_input_payload_from_messages(input_messages),
336365
)
337366
if isinstance(modified, str) and modified != response.content:
338367
response.content = modified
@@ -361,7 +390,13 @@ def invoke(self, messages: Any, config: Any = None, **kwargs: Any) -> Any:
361390
GuardrailExecutionStage.POST,
362391
GuardrailExecutionStage.PRE_AND_POST,
363392
):
364-
_apply_llm_post(response, evaluator, action, name)
393+
_apply_llm_post(
394+
response,
395+
evaluator,
396+
action,
397+
name,
398+
input_messages=messages if isinstance(messages, list) else None,
399+
)
365400
return response
366401

367402
async def ainvoke(
@@ -377,7 +412,13 @@ async def ainvoke(
377412
GuardrailExecutionStage.POST,
378413
GuardrailExecutionStage.PRE_AND_POST,
379414
):
380-
_apply_llm_post(response, evaluator, action, name)
415+
_apply_llm_post(
416+
response,
417+
evaluator,
418+
action,
419+
name,
420+
input_messages=messages if isinstance(messages, list) else None,
421+
)
381422
return response
382423

383424
llm.__class__ = _GuardedLLM
@@ -458,6 +499,7 @@ def _apply_agent_output_guardrail(
458499
scope=GuardrailScope.AGENT,
459500
stage=GuardrailExecutionStage.POST,
460501
component=component_label(GuardrailScope.AGENT),
502+
input_payload=_input_payload_from_messages(messages),
461503
)
462504
if isinstance(modified, str) and modified != text:
463505
_apply_message_text_modification(msg, modified)

tests/cli/test_guardrails_in_langgraph.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -878,7 +878,7 @@ class TestDecoratorEscalation:
878878
The adapter fires interrupt() before the guarded agent's real invoke, so — like
879879
the joke-agent-decorator sample — the guarded agent is embedded in an outer
880880
graph node, which gives interrupt() the graph/checkpoint context it needs.
881-
Payload (Component/ExecutionStage/ToolInputs) matches TestMiddlewareEscalation.
881+
Payload (Component/ExecutionStage/Inputs) matches TestMiddlewareEscalation.
882882
"""
883883

884884
@pytest.fixture(autouse=True)
@@ -948,7 +948,7 @@ async def test_escalation_suspends_with_context_derived_payload(self) -> None:
948948
# adapter publishes — identical to the middleware flavor.
949949
assert cre.data["Component"] == "Agent"
950950
assert cre.data["ExecutionStage"] == "PreExecution"
951-
assert cre.data["ToolInputs"] == json.dumps("joke about a@b.com")
951+
assert cre.data["Inputs"] == json.dumps("joke about a@b.com")
952952
assert cre.data["GuardrailName"] == "PII escalation guardrail"
953953

954954
@pytest.mark.asyncio

tests/guardrails/test_adapter_context_publishing.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
tests guard that too.
1313
"""
1414

15+
import json
1516
from typing import Any
1617

1718
import pytest
@@ -94,6 +95,7 @@ def test_pre_publishes_tool_pre_context_with_tool_name(self) -> None:
9495
assert action.seen.scope == GuardrailScope.TOOL
9596
assert action.seen.execution_stage == GuardrailExecutionStage.PRE
9697
assert action.seen.component == "my_tool"
98+
assert action.seen.input_payload is None # no separate input at PRE
9799

98100
def test_post_publishes_tool_post_context(self) -> None:
99101
action = _RecordingAction()
@@ -105,6 +107,8 @@ def test_post_publishes_tool_post_context(self) -> None:
105107
assert action.seen.scope == GuardrailScope.TOOL
106108
assert action.seen.execution_stage == GuardrailExecutionStage.POST
107109
assert action.seen.component == "my_tool"
110+
# POST carries the original tool input so the action shows both sides.
111+
assert action.seen.input_payload == json.dumps({"text": "a@b.com"})
108112

109113
def test_context_reset_after_invoke(self) -> None:
110114
action = _RecordingAction()
@@ -136,14 +140,23 @@ def test_pre_publishes_llm_pre_context(self) -> None:
136140
assert action.seen.scope == GuardrailScope.LLM
137141
assert action.seen.execution_stage == GuardrailExecutionStage.PRE
138142
assert action.seen.component == "LLM call"
143+
assert action.seen.input_payload is None # no separate input at PRE
139144

140145
def test_post_publishes_llm_post_context(self) -> None:
141146
action = _RecordingAction()
142-
_apply_llm_post(AIMessage(content="answer a@b.com"), _fail_eval, action, "g")
147+
_apply_llm_post(
148+
AIMessage(content="answer a@b.com"),
149+
_fail_eval,
150+
action,
151+
"g",
152+
input_messages=[HumanMessage(content="hi a@b.com")],
153+
)
143154
assert action.seen is not None
144155
assert action.seen.scope == GuardrailScope.LLM
145156
assert action.seen.execution_stage == GuardrailExecutionStage.POST
146157
assert action.seen.component == "LLM call"
158+
# POST carries the original human input so the action shows both sides.
159+
assert action.seen.input_payload == json.dumps("hi a@b.com")
147160

148161
def test_context_reset_after_call(self) -> None:
149162
action = _RecordingAction()
@@ -179,11 +192,17 @@ def test_input_publishes_agent_pre_context(self) -> None:
179192
assert action.seen.scope == GuardrailScope.AGENT
180193
assert action.seen.execution_stage == GuardrailExecutionStage.PRE
181194
assert action.seen.component == "Agent"
195+
assert action.seen.input_payload is None # no separate input at PRE
182196

183197
def test_output_publishes_agent_post_context(self) -> None:
184198
action = _RecordingAction()
185199
_apply_agent_output_guardrail(
186-
{"messages": [AIMessage(content="answer a@b.com")]},
200+
{
201+
"messages": [
202+
HumanMessage(content="hi a@b.com"),
203+
AIMessage(content="answer a@b.com"),
204+
]
205+
},
187206
_fail_eval,
188207
action,
189208
"g",
@@ -192,6 +211,8 @@ def test_output_publishes_agent_post_context(self) -> None:
192211
assert action.seen.scope == GuardrailScope.AGENT
193212
assert action.seen.execution_stage == GuardrailExecutionStage.POST
194213
assert action.seen.component == "Agent"
214+
# POST carries the original human input (from the output messages).
215+
assert action.seen.input_payload == json.dumps("hi a@b.com")
195216

196217
def test_context_reset_after_call(self) -> None:
197218
action = _RecordingAction()

0 commit comments

Comments
 (0)