Skip to content
Draft
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
3 changes: 3 additions & 0 deletions docs/index.yml
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,9 @@ navigation:
- endpoint: GET /
title: Get Server Health or Chat UI
slug: get-root
- page: Migrating to 0.23
path: migration/0.23.mdx
slug: 0-23
- page: Migrating to 0.22
path: migration/0.22.mdx
slug: 0-22
Expand Down
14 changes: 14 additions & 0 deletions docs/migration/0.23.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
title: "Migrating to 0.23"
sidebar-title: "Migrating to 0.23"
description: Behavior changes to account for when updating a working NeMo Guardrails configuration to 0.23.
keywords: ["nemoguardrails 0.23", "migration", "LLMRails", "explain"]
content:
type: "how_to"
---

# Migrating to v0.23.0

## Behavior Changes

- `LLMRails.explain().llm_calls` now reflects only the most recent `generate`/`generate_async` call instead of accumulating across calls on a reused `LLMRails` instance. This matches the documented "latest call" contract and is a side effect of per-request context isolation in the decomposed generation pipeline.
16 changes: 16 additions & 0 deletions nemoguardrails/rails/llm/checks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: Copyright (c) 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.

__all__ = []
168 changes: 168 additions & 0 deletions nemoguardrails/rails/llm/checks/rails_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# 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.

"""Message rail check helpers."""

import logging
from copy import deepcopy
from dataclasses import dataclass
from typing import Any, List, Optional, Protocol

from nemoguardrails.rails.llm.options import (
GenerationResponse,
RailsResult,
RailStatus,
RailType,
)

log = logging.getLogger(__name__)

__all__ = ["RailsCheckRuntime", "check_messages"]


class RailsCheckRuntime(Protocol):
config: Any
runtime: Any

async def generate_async(
self,
*,
messages: List[dict],
options: dict,
) -> object: ...


@dataclass
class RailsCheckPlan:
messages: List[dict]
options: dict
original_content: str


async def check_messages(
rails: RailsCheckRuntime,
messages: List[dict],
rail_types: Optional[List[RailType]] = None,
) -> RailsResult:
"""Run input/output rails for a message list and return rail check status."""
plan = _plan_rails_check(messages, rail_types)
if plan is None:
last_content = messages[-1].get("content", "") if messages else ""
return RailsResult(status=RailStatus.PASSED, content=last_content)

response = await rails.generate_async(messages=plan.messages, options=plan.options)

if not isinstance(response, GenerationResponse):
raise RuntimeError(f"Expected GenerationResponse, got {type(response).__name__}")

return _classify_rails_response(response, plan.original_content)


def _plan_rails_check(
messages: List[dict],
rail_types: Optional[List[RailType]] = None,
) -> Optional[RailsCheckPlan]:
if rail_types is not None:
options: Optional[dict] = {"rails": [r.value for r in rail_types]}
else:
options = _determine_rails_from_messages(messages)

if options is None:
return None

rails_to_run = options["rails"]
if "output" in rails_to_run:
original_content = _get_last_content_by_role(messages, "assistant")
else:
original_content = _get_last_content_by_role(messages, "user")

messages = _normalize_messages_for_rails(deepcopy(messages), rails_to_run)
options["log"] = {"activated_rails": True}

return RailsCheckPlan(
messages=messages,
options=options,
original_content=original_content,
)


def _classify_rails_response(
response: GenerationResponse,
original_content: str,
) -> RailsResult:
blocking_rail = _get_blocking_rail(response)
result_content = _get_last_response_content(response)

if blocking_rail:
return RailsResult(status=RailStatus.BLOCKED, content=result_content, rail=blocking_rail)

if result_content != original_content:
return RailsResult(status=RailStatus.MODIFIED, content=result_content)
return RailsResult(status=RailStatus.PASSED, content=result_content)


def _determine_rails_from_messages(messages: List[dict]) -> Optional[dict]:
roles = {msg.get("role") for msg in reversed(messages)}
has_user = "user" in roles
has_assistant = "assistant" in roles

if not has_user and not has_assistant:
log.warning(
"check() called with no user or assistant messages. "
"Only system, context, or tool messages found. "
"Returning passing result without running rails."
)
return None

if has_user and has_assistant:
return {"rails": ["input", "output"]}
if has_user:
return {"rails": ["input"]}
return {"rails": ["output"]}


def _normalize_messages_for_rails(
messages: List[dict],
rails: List[str],
) -> List[dict]:
if rails == ["output"]:
has_user = any(msg.get("role") == "user" for msg in messages)
if not has_user:
return [{"role": "user", "content": ""}] + messages

return messages


def _get_last_content_by_role(messages: List[dict], role: str) -> str:
for msg in reversed(messages):
if msg.get("role") == role:
return msg.get("content", "")
return ""


def _get_blocking_rail(response: GenerationResponse) -> Optional[str]:
if response.log and response.log.activated_rails:
for rail in response.log.activated_rails:
if rail.stop:
return rail.name
return None


def _get_last_response_content(response: GenerationResponse) -> str:
if isinstance(response.response, list) and response.response:
return response.response[-1].get("content", "")
if isinstance(response.response, str):
return response.response
return ""
Loading