From 47bff66b847bf2342ecf1448b996b6413026a521 Mon Sep 17 00:00:00 2001 From: Muneeza Azmat Date: Wed, 22 Jul 2026 22:29:03 -0700 Subject: [PATCH] fix(v2): clear output_rails_in_progress after output-rail abort When an output rail aborted, run output rails never cleared the reentrancy flag, so later turns in the same session skipped output rails. Catch success and failure with when/else, reset the flag on both paths, and re-abort on failure so blocked text is still withheld. Fixes #2067 Signed-off-by: Muneeza Azmat Co-authored-by: Cursor --- .../colang/v2_x/library/guardrails.co | 12 +- .../v2_x/test_output_rails_abort_multiturn.py | 130 ++++++++++++++++++ 2 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 tests/v2_x/test_output_rails_abort_multiturn.py diff --git a/nemoguardrails/colang/v2_x/library/guardrails.co b/nemoguardrails/colang/v2_x/library/guardrails.co index 9f6e1de009..bb9640463c 100644 --- a/nemoguardrails/colang/v2_x/library/guardrails.co +++ b/nemoguardrails/colang/v2_x/library/guardrails.co @@ -93,7 +93,11 @@ flow run output rails $output_text $output_rails_exist = await CheckFlowDefinedAction(flow_id="output rails") if $output_rails_exist log "found output rails, checking..." - await output rails $output_text - log "output rails passed" - - $output_rails_in_progress = False + when output rails $output_text + log "output rails passed" + $output_rails_in_progress = False + else + $output_rails_in_progress = False + abort + else + $output_rails_in_progress = False diff --git a/tests/v2_x/test_output_rails_abort_multiturn.py b/tests/v2_x/test_output_rails_abort_multiturn.py new file mode 100644 index 0000000000..1e5a06cafb --- /dev/null +++ b/tests/v2_x/test_output_rails_abort_multiturn.py @@ -0,0 +1,130 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Regression for #2067: output rails must keep firing after an abort.""" + +from nemoguardrails import LLMRails, RailsConfig +from nemoguardrails.utils import new_event_dict, new_uuid + +colang_content = """ +import core +import guardrails + +flow output rails $output_text + if $output_text == "BLOCKME" + bot say "Rejected." + abort + +@active +flow handle user messages + while True + user said something + bot say "BLOCKME" + +flow main + activate handle user messages + wait indefinitely +""" + +yaml_content = """ +colang_version: "2.x" +models: [] +""" + + +def _setup_rails() -> LLMRails: + config = RailsConfig.from_content(colang_content, yaml_content) + app = LLMRails(config) + app.runtime.disable_async_execution = True + return app + + +def _finish_bot_actions(app: LLMRails, state, events: list): + for event in events: + if event["type"] == "StartUtteranceBotAction": + state = app.process_events( + [ + new_event_dict( + "UtteranceBotActionFinished", + action_uid=event["action_uid"], + is_success=True, + final_script=event["script"], + ) + ], + state, + )[1] + return state + + +def _user_said(text: str) -> list: + uid = new_uuid() + return [ + new_event_dict("UtteranceUserActionStarted", action_uid=uid), + new_event_dict( + "UtteranceUserActionFinished", + final_transcript=text, + action_uid=uid, + is_success=True, + ), + ] + + +def _bot_scripts(events: list) -> list[str]: + return [event["script"] for event in events if event["type"] == "StartUtteranceBotAction"] + + +def test_output_rails_fire_on_every_turn_after_abort(): + """After an output-rail abort, later turns must still run output rails.""" + app = _setup_rails() + state = None + + for turn in ("hello", "hi again", "one more"): + out, state = app.process_events(_user_said(turn), state) + state = _finish_bot_actions(app, state, out) + scripts = _bot_scripts(out) + assert scripts == ["Rejected."], f"turn {turn!r}: expected output rail to reject BLOCKME, got {scripts!r}" + + +def test_output_rails_pass_through_still_clears_flag(): + """Successful output rails must clear the flag so later bot says still work.""" + colang = """ +import core +import guardrails + +flow output rails $output_text + global $bot_message + $bot_message = "ok:{$output_text}" + +@active +flow handle user messages + while True + user said something + bot say "passthrough" + +flow main + activate handle user messages + wait indefinitely +""" + config = RailsConfig.from_content(colang, yaml_content) + app = LLMRails(config) + app.runtime.disable_async_execution = True + state = None + + for turn in ("alpha", "beta"): + out, state = app.process_events(_user_said(turn), state) + state = _finish_bot_actions(app, state, out) + assert _bot_scripts(out) == ["ok:passthrough"], ( + f"turn {turn!r}: expected transformed output, got {_bot_scripts(out)!r}" + )