Skip to content
Open
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
9 changes: 6 additions & 3 deletions nemoguardrails/base_guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@
"""

from abc import ABC, abstractmethod
from typing import Any, AsyncIterator
from typing import Any, AsyncIterator, Optional, Union

from nemoguardrails.rails.llm.config import RailsConfig
from nemoguardrails.rails.llm.options import GenerationOptions


class BaseGuardrails(ABC):
Expand All @@ -44,12 +45,14 @@ class BaseGuardrails(ABC):
config: RailsConfig

@abstractmethod
def generate(self, *args: Any, **kwargs: Any) -> Any:
def generate(self, *args: Any, options: Optional[Union[dict, GenerationOptions]] = None, **kwargs: Any) -> Any:
"""Generate an LLM response synchronously with guardrails applied."""
...

@abstractmethod
async def generate_async(self, *args: Any, **kwargs: Any) -> Any:
async def generate_async(
self, *args: Any, options: Optional[Union[dict, GenerationOptions]] = None, **kwargs: Any
) -> Any:
"""Generate an LLM response asynchronously with guardrails applied."""
...

Expand Down
13 changes: 8 additions & 5 deletions nemoguardrails/guardrails/actions/content_safety_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,13 @@ def _content_safety_to_rail_result(parsed: object) -> RailResult:
"""
if isinstance(parsed, (list, tuple)):
if parsed and parsed[0] is True:
return RailResult(is_safe=True)
return RailResult(is_safe=True, return_value={"allowed": True, "policy_violations": []})
if parsed and parsed[0] is False:
if len(parsed) > 1:
categories = ", ".join(str(c) for c in parsed[1:])
return RailResult(is_safe=False, reason=f"Safety categories: {categories}")
return RailResult(is_safe=False, reason="Unknown")
violations = [str(c) for c in parsed[1:]]
verdict = {"allowed": False, "policy_violations": violations}
if violations:
return RailResult(
is_safe=False, reason=f"Safety categories: {', '.join(violations)}", return_value=verdict
)
return RailResult(is_safe=False, reason="Unknown", return_value=verdict)
raise RuntimeError(f"Unexpected content safety parse result: {parsed}")
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def _parse_response(self, response: Any) -> RailResult:
raise RuntimeError(f"Jailbreak response missing 'jailbreak' field: {response}")

score = response.get("score", "unknown")
if response["jailbreak"]:
return RailResult(is_safe=False, reason=f"Score: {score}")
return RailResult(is_safe=True, reason=f"Score: {score}")
is_jailbreak = response["jailbreak"]
if is_jailbreak:
return RailResult(is_safe=False, reason=f"Score: {score}", return_value=is_jailbreak)
return RailResult(is_safe=True, reason=f"Score: {score}", return_value=is_jailbreak)
Comment on lines +44 to +47

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.

P2 The return_value here is a raw bool (the jailbreak field), while content_safety_action returns {"allowed": bool, "policy_violations": list} and topic_safety_action returns {"on_topic": bool}. Consumers iterating GenerationLog.activated_rails[].executed_actions[].return_value will see inconsistent types across rail kinds; a dict keeps all three uniform.

Suggested change
is_jailbreak = response["jailbreak"]
if is_jailbreak:
return RailResult(is_safe=False, reason=f"Score: {score}", return_value=is_jailbreak)
return RailResult(is_safe=True, reason=f"Score: {score}", return_value=is_jailbreak)
is_jailbreak = response["jailbreak"]
verdict = {"jailbreak": is_jailbreak, "score": score}
if is_jailbreak:
return RailResult(is_safe=False, reason=f"Score: {score}", return_value=verdict)
return RailResult(is_safe=True, reason=f"Score: {score}", return_value=verdict)
Prompt To Fix With AI
This is a comment left during a code review.
Path: nemoguardrails/guardrails/actions/jailbreak_detection_action.py
Line: 44-47

Comment:
The `return_value` here is a raw `bool` (the `jailbreak` field), while `content_safety_action` returns `{"allowed": bool, "policy_violations": list}` and `topic_safety_action` returns `{"on_topic": bool}`. Consumers iterating `GenerationLog.activated_rails[].executed_actions[].return_value` will see inconsistent types across rail kinds; a dict keeps all three uniform.

```suggestion
        is_jailbreak = response["jailbreak"]
        verdict = {"jailbreak": is_jailbreak, "score": score}
        if is_jailbreak:
            return RailResult(is_safe=False, reason=f"Score: {score}", return_value=verdict)
        return RailResult(is_safe=True, reason=f"Score: {score}", return_value=verdict)
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

4 changes: 2 additions & 2 deletions nemoguardrails/guardrails/actions/topic_safety_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,5 @@ async def _get_response(self, model_type: Optional[str], prompt: Any) -> str:

def _parse_response(self, response: Any) -> RailResult:
if response.lower().strip() == "off-topic":
return RailResult(is_safe=False, reason="Topic safety: off-topic")
return RailResult(is_safe=True)
return RailResult(is_safe=False, reason="Topic safety: off-topic", return_value={"on_topic": False})
return RailResult(is_safe=True, return_value={"on_topic": True})
4 changes: 4 additions & 0 deletions nemoguardrails/guardrails/engine_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ def _get_engine(self, name: str, expected_type: type[_EngineT]) -> _EngineT:
raise TypeError(f"Engine '{name}' is {type(engine).__name__}, expected {expected_type.__name__}")
return engine

def provider_name(self, model_type: str) -> str:
"""Return the provider/engine name (e.g. 'nim', 'openai') for a model engine."""
return self._get_engine(model_type, ModelEngine).model_config.engine or "unknown"

async def model_call(self, model_type: str, messages: list[dict], **kwargs: Any) -> LLMResponse:
"""Route a chat completion request to the named model engine.

Expand Down
44 changes: 35 additions & 9 deletions nemoguardrails/guardrails/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from nemoguardrails.logging.explain import ExplainInfo
from nemoguardrails.rails.llm.config import RailsConfig
from nemoguardrails.rails.llm.llmrails import LLMRails
from nemoguardrails.rails.llm.options import GenerationResponse, RailsResult, RailType
from nemoguardrails.rails.llm.options import GenerationOptions, GenerationResponse, RailsResult, RailType
from nemoguardrails.types import LLMModel

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -205,43 +205,69 @@ async def _ensure_started(self) -> None:
await self.startup()

def generate(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
self,
prompt: str | None = None,
messages: LLMMessages | None = None,
options: Optional[Union[dict, GenerationOptions]] = None,
**kwargs,
) -> Union[str, dict, GenerationResponse, Tuple[dict, dict]]:
"""Generate an LLM response synchronously with guardrails applied.
Supported in both IORails and LLMRails
"""

generate_messages = self._convert_to_messages(prompt, messages)
return self.rails_engine.generate(messages=generate_messages, **kwargs)
return self.rails_engine.generate(messages=generate_messages, options=options, **kwargs)

@overload
async def generate_async(self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs) -> str: ...
async def generate_async(
self,
prompt: str | None = None,
messages: LLMMessages | None = None,
options: Optional[Union[dict, GenerationOptions]] = None,
**kwargs,
) -> str: ...

@overload
async def generate_async(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
self,
prompt: str | None = None,
messages: LLMMessages | None = None,
options: Optional[Union[dict, GenerationOptions]] = None,
**kwargs,
) -> dict: ...

@overload
async def generate_async(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
self,
prompt: str | None = None,
messages: LLMMessages | None = None,
options: Optional[Union[dict, GenerationOptions]] = None,
**kwargs,
) -> GenerationResponse: ...

@overload
async def generate_async(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
self,
prompt: str | None = None,
messages: LLMMessages | None = None,
options: Optional[Union[dict, GenerationOptions]] = None,
**kwargs,
) -> tuple[dict, dict]: ...
Comment on lines 221 to 255

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant method and nearby definitions.
file="nemoguardrails/guardrails/guardrails.py"
wc -l "$file"
sed -n '180,310p' "$file"

# Find the concrete implementation and any related overloads in the repo.
rg -n "`@overload`|def generate_async|def generate\(" nemoguardrails/guardrails/guardrails.py nemoguardrails -g '*.py'

Repository: NVIDIA-NeMo/Guardrails

Length of output: 7333


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the underlying LLMRails overloads and any public type declarations.
sed -n '1340,1415p' nemoguardrails/rails/llm/llmrails.py
sed -n '1415,1515p' nemoguardrails/rails/llm/llmrails.py
sed -n '220,290p' nemoguardrails/types.py
sed -n '1,90p' nemoguardrails/base_guardrails.py

Repository: NVIDIA-NeMo/Guardrails

Length of output: 11942


Make the async overloads distinguish options or collapse them into one union return type.

All four generate_async overloads take the same parameters, so type checkers will always pick the first str overload and lose the GenerationResponse/dict/tuple return types. Split the options=None case from the structured-response case, or replace the overload set with one accurate union return annotation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemoguardrails/guardrails/guardrails.py` around lines 221 - 255, The
generate_async overloads currently share identical parameters, causing type
checkers to select only the first str return type. Update generate_async so
overloads distinguish calls by the options value, or replace them with a single
accurate union return annotation covering str, dict, GenerationResponse, and
tuple[dict, dict].


async def generate_async(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
self,
prompt: str | None = None,
messages: LLMMessages | None = None,
options: Optional[Union[dict, GenerationOptions]] = None,
**kwargs,
) -> str | dict | GenerationResponse | tuple[dict, dict]:
"""Generate an LLM response asynchronously with guardrails applied.
Supported by both LLMRails and IORails
"""
await self._ensure_started()

generate_messages = self._convert_to_messages(prompt, messages)
return await self.rails_engine.generate_async(messages=generate_messages, **kwargs)
return await self.rails_engine.generate_async(messages=generate_messages, options=options, **kwargs)

def stream_async(
self, prompt: str | None = None, messages: LLMMessages | None = None, **kwargs
Expand Down
61 changes: 58 additions & 3 deletions nemoguardrails/guardrails/guardrails_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@

import secrets
from contextvars import ContextVar, Token
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, TypeAlias
from typing import Any, Optional, TypeAlias

from nemoguardrails.types import UsageInfo

# LLMMessage can contain role/content, plus optional tool_calls / tool_call_id / name; content may be None
LLMMessage: TypeAlias = dict[str, Any]
Expand All @@ -32,13 +34,56 @@ class RailDirection(Enum):
OUTPUT = "Output"


@dataclass(frozen=True, slots=True)
class RailCallRecord:
"""One rail's execution record, carried on RailResult for GenerationLog synthesis.
Captures what a single rail did — its verdict and the (at most one) model call it
made — as engine-neutral data. IORails maps a ``RailCallRecord`` to an
``ActivatedRail`` (with a single synthetic ``ExecutedAction`` and ``LLMCallInfo``);
the raw ``usage``/timing is kept here so this module stays free of the pydantic
``GenerationLog`` types. Tool rails that make no model call leave ``usage`` None.
"""

flow: str
rail_type: str
is_safe: bool
made_call: bool = False
action_name: Optional[str] = None
return_value: Any = None
task: Optional[str] = None
request_id: Optional[str] = None
usage: Optional[UsageInfo] = None
llm_model_name: Optional[str] = None
llm_provider_name: Optional[str] = None
prompt: Optional[str] = None
completion: Optional[str] = None
started_at: Optional[float] = None
finished_at: Optional[float] = None
duration: Optional[float] = None


@dataclass(frozen=True, slots=True)
class RailResult:
"""Result of a rail safety check."""
"""Result of a rail safety check.
``records`` carries the per-rail execution records for every rail that ran in this
check (not just the blocking one), so IORails can synthesize a ``GenerationLog``.
It is empty unless log collection is active. ``return_value`` is the rail's
structured verdict (e.g. ``{"allowed": ..., "policy_violations": [...]}``) when the
action supplies one, used as the log's ``ExecutedAction.return_value``.
``records`` and ``return_value`` are log-capture metadata, not part of the safety
verdict, so they are excluded from equality and hashing (``compare=False``) — two
results with the same ``is_safe``/``reason``/``triggered_rail`` compare equal
regardless of captured log data.
"""

is_safe: bool
reason: str | None = None
triggered_rail: str | None = None
records: tuple[RailCallRecord, ...] = field(default=(), compare=False)
return_value: Any = field(default=None, compare=False)


# Default max character length for truncate(). Used to keep DEBUG log lines short.
Expand Down Expand Up @@ -84,3 +129,13 @@ def truncate(text: object, max_len: int | None = None) -> str:
if len(s) <= limit:
return s
return s[:limit] + "..."


def serialize_prompt(messages: list[dict]) -> str:
"""Render a chat message list to a role-labeled string for GenerationLog's ``prompt``.
Content parity with LLMRails' logged prompt, not byte-for-byte format parity: each
message becomes ``"<role>: <content>"`` and messages are blank-line separated. A
message with no content (e.g. a reasoning-only or tool-call turn) renders as empty.
"""
return "\n\n".join(f"{m.get('role', '')}: {m.get('content') or ''}" for m in messages)
Comment on lines +134 to +141

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve non-text fields in captured prompts.

Line 141 discards fields such as tool_calls, tool_call_id, names, and reasoning-only metadata, so GenerationLog.llm_calls[*].prompt can misrepresent the actual model request. Use the existing canonical message serialization or encode all message fields, with coverage for tool-call and reasoning-only turns.

As per coding guidelines, preserve non-text model metadata and do not drop reasoning-only content.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nemoguardrails/guardrails/guardrails_types.py` around lines 134 - 141, Update
serialize_prompt so captured prompts preserve all message metadata, including
tool_calls, tool_call_id, names, and reasoning-only fields, instead of
formatting only role and content. Reuse the existing canonical message
serialization if available; otherwise encode each complete message structure
while retaining the established prompt ordering and separation. Add coverage for
tool-call and reasoning-only turns.

Source: Coding guidelines

Loading