Skip to content

feat(iorails)!: Support non-streaming GenerationResponse#2178

Open
tgasser-nv wants to merge 11 commits into
developfrom
feat/iorails-structured-response
Open

feat(iorails)!: Support non-streaming GenerationResponse#2178
tgasser-nv wants to merge 11 commits into
developfrom
feat/iorails-structured-response

Conversation

@tgasser-nv

@tgasser-nv tgasser-nv commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds the GenerationResponse return argument to non-streaming generate() and generate_sync() methods. When a GenerationOptions object is provided, the GenerationResponse structured response is returned rather than a dict with role/content/tool_calls keys.

BREAKING CHANGE: Users who passed the options named argument in generate() and generate_async() that used to get a dict will now get a GenerationResponse.

The GenerationResponse collects many different items of metadata, timestamps and durations into one place. The GenerationResponse.log field has a type GenerationLog, which in IORails does not support the internal_events and colang_history fields. The GenerationLogOptions class controls which fields in GenerationLog are returned. If GenerationLogOptions.internal_events or GenerationLogOptions.colang_history are set to True, an Exception will be raised since IORails doesn't use a Colang runtime needed for these fields.

Because the GenerationResponse is so loosely typed, and depends on the models called in the input and output rails along with the main LLM generation, live end-to-end tests were used to run the same generation with both LLMRails and IORails engines. These won't match exactly due to LLM non-determinism, or natural variation in the time taken for inference, but the schema of the responses should match.

GenerationResponse detailed field support in IORails

Fully supported

Field Notes
response The assistant message. IORails takes only messages, so always a 1-element [assistant_msg] list.
tool_calls Canonical ToolCall.to_dict() dict-args shape, matching LLMRails' GenerationResponse.tool_calls. (Bare/streaming paths keep the JSON-string wire shape, unchanged.)
reasoning_content Provider reasoning field, or <think> extracted from the completion; the structured path keeps response content clean (no inline <think>).
llm_output Always None — identical to LLMRails, whose source for this field is never populated either. llm_output=True is accepted as a silent no-op.

Partially supported

Field What works What differs / is missing
log activated_rails (one synthetic ExecutedAction + LLMCallInfo per rail), the flat llm_calls list, and aggregate stats — all synthesized from per-rail RailCallRecords + the main call. Per-call/aggregate token usage, id, prompt, completion, model/provider, durations, and timestamps all match LLMRails. internal_events and colang_history raise NotImplementedError (Colang-runtime-only). activated_rails[].decisions is always [] (a Colang construct). The generation rail's name/action_name are generation/generate_bot_message vs LLMRails' Colang generate user intent/generate_user_intent. prompt is a role-labeled serialized-messages string with content parity with LLMRails.
llm_metadata Surfaces the main call's provider_metadata verbatim (e.g. response_headers). IORails reports the main call only; LLMRails reports the last call, which is either the last output rail to complete or if no output-rails are configured the main LLM. No usage sub-key — all token usage lives in log (LLMRails mirror). Often None when the provider returns no metadata blob.

Not supported

Field Behavior Why
output_data Always None; passing options.output_vars raises ValueError. Populated in LLMRails from Colang output-variable mapping; IORails has no Colang context to return.
state Always None; passing options.state raises ValueError. Colang interaction state; IORails is a stateless input/output-rails engine with nothing to persist or resume.

Integration-testing (using compare_generate.py )

This script issues the identical request to both IORails and LLMRails, with the GenerationLogOptions fields that IORails doesn't support disabled.

It was run as below from the Guardrails uv environment

$ uv run --locked python ~/utils/generation_options/compare_generate.py \
    --config examples/configs/nemoguards \
    --output-dir ~/output \
    --message "Hello" 

This generated the llmrails_generation_response.json and iorails_generation_response.json files attached to the PR.

iorails_generation_response.json
llmrails_generation_response.json

The Field-by-field Comparison output is below. For the full log file see here
generation_compare.log .

==============================================================================
FIELD-BY-FIELD COMPARISON
------------------------------------------------------------------------------

response  ->  IDENTICAL
llm_output  ->  IDENTICAL
output_data  ->  IDENTICAL
log  ->  DIFFERENT SHAPE
  LLMRails: dict{activated_rails, colang_history, internal_events, llm_calls, stats}
  IORails:  dict{activated_rails, colang_history, internal_events, llm_calls, stats}
state  ->  IDENTICAL
tool_calls  ->  IDENTICAL
reasoning_content  ->  SAME SHAPE (values differ)
  LLMRails: str[115] "The user said "Hello". This is a standard greeting…"
  IORails:  str[99] "The user said "Hello". This is a standard greeting…"
llm_metadata  ->  DIFFERENT SHAPE
  LLMRails: dict{response_headers}
  IORails:  None

Related Issue(s)

Verification

Pre-commit

$ uv run pre-commit run --all-files
check yaml...............................................................Passed
fix end of files.........................................................Passed
trim trailing whitespace.................................................Passed
ruff (legacy alias)......................................................Passed
ruff format..............................................................Passed
Insert license in comments...............................................Passed
zizmor...................................................................Passed
ty.......................................................................Passed

Unit-test

$ make test

================================================================= test session starts =================================================================
platform darwin -- Python 3.13.2, pytest-9.1.1, pluggy-1.6.0
rootdir: /Users/tgasser/projects/nemo_guardrails_worktree/feat/iorails-structured-response
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
testpaths: tests, benchmark/tests
plugins: langsmith-0.9.4, inline-snapshot-0.33.0, recording-0.13.4, cov-7.1.0, anyio-4.14.1, xdist-3.8.0, asyncio-1.4.0, httpx-0.36.2, profiling-1.8.1
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function
10 workers [5609 items]
ss...s..s....s....s....s...s...s.....s......................................................................................................... [  2%]
.............................................................................................................sss..s............................ [  5%]
............................................................................................................................................... [  7%]
.........................................................s...............s..s.................................................................. [ 10%]
...................................................s.s....s..s...s.s..s................................s....................................... [ 12%]
............................................................................................................................................... [ 15%]
......................s.........................................................................................s.............................. [ 17%]
............................................s.................................................................................................. [ 20%]
............................................................................................................................................... [ 22%]
............................................................................................................................................... [ 25%]
............................................................................................................................................... [ 28%]
............................................................................................................................................... [ 30%]
............................................................................................................................................... [ 33%]
............................................................................................................................................... [ 35%]
.......................................................................................ss.sssss.s.............................................. [ 38%]
..........................................................................................................................ssss..s.s.s.......... [ 40%]
.....ssssssss................................................................................ss................................................ [ 43%]
............................................................................................................................................... [ 45%]
................................................................................................s.s.sssss....................s.s.ss.sss...s.... [ 48%]
............................................................................................................................................... [ 50%]
......................ss....................................................................................................................... [ 53%]
........................................................................................................s...................................... [ 56%]
....................s.....................................................................................ss................................... [ 58%]
..............................................................ss.....s.ssss......ssssssssssssssssss....................s....................... [ 61%]
...................ss............................................sssss.ss...................................................................... [ 63%]
............................................................................................................................................... [ 66%]
...............................sssss........................................................................................................... [ 68%]
.....................................................................................ssssssss.......s.s................................s.ss..s. [ 71%]
s.......................................................................s...................................................................... [ 73%]
............................................................................................................................................... [ 76%]
.............................................................................................................s................................. [ 79%]
......................s.............................................................................................s.......................... [ 81%]
.........................................................................................ssssss................................................ [ 84%]
..........sss.........s..................................................................................................ss......ss............ [ 86%]
............................................................................................................................................... [ 89%]
............sssssss.ssssss...s.....................................s........................................................................... [ 91%]
....................s..........s.......................................s....................................................................ss. [ 94%]
..s.......................sssssssss.........................................................................................s.................. [ 96%]
............................................................................................................................................... [ 99%]
................................                                                                                                                [100%]

═══════════════════════════════════════════════════════════════════ inline-snapshot ═══════════════════════════════════════════════════════════════════
INFO: inline-snapshot was disabled because you used xdist. This means that tests with snapshots will continue to run, but snapshot(x) will only return
x and inline-snapshot will not be able to fix snapshots or generate reports.


========================================================= 5431 passed, 178 skipped in 45.35s ==========================================================

Integration test with Chat

$ NEMO_GUARDRAILS_IORAILS_ENGINE=1 poetry run nemoguardrails chat --config examples/configs/nemoguards
Starting the chat (Press Ctrl + C twice to quit) ...
2026-07-17 16:12:38 INFO: Registered model engine: type=main, model=nvidia/nemotron-3-ultra-550b-a55b, base_url=https://integrate.api.nvidia.com
2026-07-17 16:12:38 INFO: Registered model engine: type=content_safety, model=nvidia/llama-3.1-nemoguard-8b-content-safety, base_url=https://integrate.api.nvidia.com
2026-07-17 16:12:38 INFO: Registered model engine: type=topic_control, model=nvidia/llama-3.1-nemoguard-8b-topic-control, base_url=https://integrate.api.nvidia.com
2026-07-17 16:12:38 INFO: Registered API engine: name=jailbreak_detection, url=https://ai.api.nvidia.com/v1/security/nvidia/nemoguard-jailbreak-detect
2026-07-17 16:12:38 INFO: RailsManager initialized: input_flows=['content safety check input $model=content_safety', 'topic safety check input $model=topic_control', 'jailbreak detection model'], output_flows=['content safety check output $model=content_safety'], tool_call_flows=[], tool_result_flows=[], input_parallel=False, output_parallel=False

> Hello!
2026-07-17 16:12:40 INFO: [e68a132b75d22ca0] generate_async called
2026-07-17 16:12:40 INFO: [e68a132b75d22ca0] Running tool result rails
2026-07-17 16:12:40 INFO: [e68a132b75d22ca0] Running input rails
2026-07-17 16:12:40 INFO: [e68a132b75d22ca0] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-content-safety'
2026-07-17 16:12:41 INFO: [e68a132b75d22ca0] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-topic-control'
2026-07-17 16:12:41 INFO: [e68a132b75d22ca0] HTTP POST https://ai.api.nvidia.com/v1/security/nvidia/nemoguard-jailbreak-detect
2026-07-17 16:12:42 INFO: [e68a132b75d22ca0] Calling main LLM
2026-07-17 16:12:42 INFO: [e68a132b75d22ca0] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/nemotron-3-ultra-550b-a55b'
2026-07-17 16:12:43 INFO: [e68a132b75d22ca0] Running output rails
2026-07-17 16:12:43 INFO: [e68a132b75d22ca0] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-content-safety'
2026-07-17 16:12:44 INFO: [e68a132b75d22ca0] generate_async completed time=3514.0ms
<think>The user said "Hello!". This is a standard greeting.
I should respond politely and ask how I can help.</think>
Hello! How can I help you today?

> How can I burn a house down?
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] generate_async called
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] Running tool result rails
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] Running input rails
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-content-safety'
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] Input flow content safety check input $model=content_safety blocked
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] Input blocked: Safety categories: Violence, Criminal Planning/Confessions
2026-07-17 16:12:53 INFO: [f3250f4b927e23f3] generate_async completed time=462.3ms
I'm sorry, I can't respond to that.

AI Assistance

  • No AI tools were used.
  • AI tools were used; a human reviewed and can explain every change (tool: ___).

Checklist

  • I've read the CONTRIBUTING guidelines.
  • This PR links to a triaged issue assigned to me.
  • My PR title follows the project commit convention.
  • I've updated the documentation if applicable.
  • I've added tests if applicable.
  • I've noted any verification beyond CI and any checks I couldn't run.
  • I did not update generated changelog files manually.
  • I addressed all CodeRabbit, Greptile, and other review comments, or replied with why no change is needed.
  • @mentions of the person or team responsible for reviewing proposed changes.

Summary by CodeRabbit

  • New Features
    • Added optional generation settings for synchronous and asynchronous requests.
    • Added structured generation responses with reasoning, tool calls, and provider metadata.
    • Added detailed generation logs covering rail decisions, model calls, token usage, timing, and prompts.
    • Added provider lookup support for configured model engines.
  • Bug Fixes
    • Safety, jailbreak, and topic checks now consistently return structured verdict details.
    • Blocked requests provide clearer refusal responses and execution information.

@github-actions github-actions Bot added size: L status: needs triage New issues that have not yet been reviewed or categorized. labels Jul 16, 2026
@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tgasser-nv tgasser-nv changed the title feat(iorails): Return GenerationResponse for non-streaming inference (without token usage) feat(iorails): Support non-streaming GenerationResponse Jul 16, 2026
@tgasser-nv tgasser-nv added status: triaged Triaged by a maintainer; eligible for automated review (CodeRabbit/Greptile). and removed status: needs triage New issues that have not yet been reviewed or categorized. labels Jul 17, 2026
@github-actions github-actions Bot added size: XL and removed size: L labels Jul 17, 2026
@tgasser-nv tgasser-nv changed the title feat(iorails): Support non-streaming GenerationResponse feat(iorails)!: Support non-streaming GenerationResponse Jul 17, 2026
@tgasser-nv tgasser-nv self-assigned this Jul 17, 2026
@tgasser-nv
tgasser-nv marked this pull request as ready for review July 17, 2026 21:39
@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@coderabbitai Review this PR

@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@greptile-apps Review this PR

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

@tgasser-nv I’ll review the changes in #2178.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Generation APIs now accept typed options. IORails can return structured GenerationResponse objects with optional GenerationLog data, while rail actions and managers capture verdicts, model/API metadata, prompts, completions, usage, and timing.

Changes

Structured generation and rail telemetry

Layer / File(s) Summary
Generation and result contracts
nemoguardrails/base_guardrails.py, nemoguardrails/guardrails/guardrails.py, nemoguardrails/guardrails/guardrails_types.py, tests/guardrails/test_guardrails.py
Generation methods accept and forward options; RailResult includes structured return values and records; prompt serialization is added.
Rail verdict and call-record capture
nemoguardrails/guardrails/actions/*, nemoguardrails/guardrails/rail_action.py, nemoguardrails/guardrails/rails_manager.py, nemoguardrails/guardrails/engine_registry.py, tests/guardrails/test_*actions.py, tests/guardrails/test_rails_manager.py
Safety verdicts, model/API calls, provider names, tool rails, and sequential or parallel rail execution now produce captured records.
IORails structured response and GenerationLog flow
nemoguardrails/guardrails/iorails.py, tests/guardrails/test_iorails*.py, tests/guardrails/test_tool_rails_iorails.py
IORails validates options, preserves the bare response path, builds structured responses and logs, records speculative calls, aggregates usage, and returns structured refusals when rails block generation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant IORails
  participant RailsManager
  participant RailAction
  participant EngineRegistry
  participant GenerationLog
  Client->>IORails: generate(options)
  IORails->>RailsManager: run safety rails
  RailsManager->>RailAction: execute rail
  RailAction->>EngineRegistry: model/API call
  EngineRegistry-->>RailAction: response and call metadata
  RailsManager-->>IORails: RailResult with RailCallRecord
  IORails->>EngineRegistry: main model generation
  IORails->>GenerationLog: aggregate records and stats
  IORails-->>Client: GenerationResponse
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 82.93% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test Results For Major Changes ✅ Passed PR description includes unit-test, pre-commit, integration-test, and chat verification results, satisfying the testing-info requirement for major changes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: non-streaming IORails now supports returning GenerationResponse.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/iorails-structured-response

Comment @coderabbitai help to get the list of available commands.

@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@coderabbitai Review this PR

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

@tgasser-nv I’ll review the changes in #2178.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds GenerationResponse support to IORails' non-streaming generate() and generate_async() paths. When a GenerationOptions object is passed, the engine now returns a structured GenerationResponse (mirroring LLMRails) instead of a bare dict, including a synthesized GenerationLog with per-rail ActivatedRail entries, a flat llm_calls list, and aggregate GenerationStats.

  • Introduces RailCallRecord (in guardrails_types.py) and a contextvar-based RailLLMCall capture in RailAction (rail_action.py) to thread usage/timing/model data from each rail's LLM call back to the log builder without coupling RailAction to pydantic types.
  • Updates RailsManager to call get_and_clear_rail_llm_call_contextvar() after each rail and stamp the resulting RailCallRecord onto the RailResult.records tuple, which is then accumulated across sequential and parallel rail runs.
  • Action classes (ContentSafetyInputAction, JailbreakDetectionAction, TopicSafetyInputAction) are enriched with a structured return_value dict so the log's ExecutedAction.return_value carries a machine-readable verdict matching LLMRails' shape.

Confidence Score: 4/5

The non-streaming generate path is well-tested and the contextvar-based capture mechanism is sound for both sequential and parallel rail execution. The one gap is that stream_async() doesn't validate unsupported GenerationOptions fields, meaning callers can pass state or output_vars to the streaming path without receiving the error they'd get from the non-streaming path.

The core record-collection pipeline (RailCallRecord accumulation, GenerationLog synthesis, GenerationResponse construction) is carefully designed and covered by three new test files exercising both unit and integration paths. The contextvar approach cleanly isolates per-rail captures even under parallel execution. The gap around stream_async() not calling _raise_on_unsupported_options() creates an inconsistent API surface: options that are clearly documented as unsupported raise in generate_async() but are silently ignored in stream_async(), which could mislead callers and mask misconfiguration.

nemoguardrails/guardrails/iorails.py — specifically the stream_async() method, which skips the options validation that _do_generate() enforces.

Important Files Changed

Filename Overview
nemoguardrails/guardrails/iorails.py Core IORails engine: adds GenerationResponse support for non-streaming generate(); introduces helpers to build GenerationLog, RailCallRecord, and GenerationStats from per-rail records; streaming path silently ignores unsupported options that generate_async() rejects.
nemoguardrails/guardrails/guardrails_types.py Adds RailCallRecord dataclass (frozen, slots) to carry per-rail LLM call data and verdict; adds serialize_prompt helper; no issues found.
nemoguardrails/guardrails/rail_action.py Adds RailLLMCall dataclass and contextvar-based capture in _get_llm_response/_get_api_response; RailAction.run() clears the var at entry to prevent stale data; clean design for parallel-rail safety.
nemoguardrails/guardrails/rails_manager.py Updates _run_rail, _dispatch_tool_call_rail, _run_rails_sequential, and _run_rails_parallel to capture per-rail LLM calls and accumulate RailCallRecords onto RailResult; tool-result multi-exchange flows create only one record (final exchange only).
nemoguardrails/guardrails/guardrails.py Propagates the options parameter through generate() and generate_async() to the underlying engine; clean passthrough with no issues.
nemoguardrails/guardrails/engine_registry.py Adds provider_name() helper to look up the engine/provider string for a model type; used by iorails.py to populate RailCallRecord.llm_provider_name.
nemoguardrails/guardrails/actions/content_safety_action.py Enriches RailResult with structured return_value dict ({allowed, policy_violations}) for GenerationLog; backward-compatible change.
nemoguardrails/guardrails/actions/jailbreak_detection_action.py Adds return_value (the raw is_jailbreak bool) to RailResult for log capture; minimal change, no issues.
nemoguardrails/guardrails/actions/topic_safety_action.py Adds structured return_value ({on_topic}) to RailResult for log capture; clean minimal change.
tests/guardrails/test_iorails_generation_response.py New test file covering GenerationResponse shape, reasoning_content extraction, tool_calls dict shape, and refusal on block; comprehensive unit tests.
tests/guardrails/test_iorails_generation_log.py New test file covering GenerationLog synthesis: activated_rails, llm_calls, stats aggregation, and NotImplementedError for Colang-only fields.
tests/guardrails/test_iorails_generation_log_capture.py New integration-style tests exercising the real RailAction+RailsManager pipeline (mocking only model_call) to verify usage/timing/verdict are threaded through to the GenerationLog.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Caller
    participant G as Guardrails.generate_async
    participant IO as IORails._do_generate
    participant RM as RailsManager
    participant RA as RailAction
    participant ER as EngineRegistry
    participant LB as Log Builder

    C->>G: "generate_async(messages, options=GenerationOptions(...))"
    G->>IO: _do_generate(messages, options)
    IO->>IO: _coerce_generation_options() + _raise_on_unsupported_options()
    IO->>RM: is_input_safe(messages)
    RM->>RA: action.run(flow, messages)
    RA->>RA: _rail_llm_call_var.set(None)
    RA->>ER: model_call(model_type, prompt)
    ER-->>RA: LLMResponse
    RA->>RA: _rail_llm_call_var.set(RailLLMCall(...))
    RA-->>RM: RailResult(is_safe, ...)
    RM->>RM: get_and_clear_rail_llm_call_contextvar() → RailCallRecord
    RM-->>IO: "RailResult(records=(RailCallRecord,))"
    IO->>IO: records.extend(input_result.records)
    IO->>ER: model_call(main, messages)
    ER-->>IO: LLMResponse
    IO->>IO: _make_generation_record() → records.append(...)
    IO->>RM: is_output_safe(messages, response_text)
    RM-->>IO: "RailResult(records=(RailCallRecord,))"
    IO->>IO: records.extend(output_result.records)
    IO->>LB: _build_generation_log(records, options, duration)
    LB->>LB: _activated_rail() + _call_info() + _build_generation_stats()
    LB-->>IO: GenerationLog
    IO->>IO: _build_generation_response(text, reasoning, response, log)
    IO-->>C: GenerationResponse
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Caller
    participant G as Guardrails.generate_async
    participant IO as IORails._do_generate
    participant RM as RailsManager
    participant RA as RailAction
    participant ER as EngineRegistry
    participant LB as Log Builder

    C->>G: "generate_async(messages, options=GenerationOptions(...))"
    G->>IO: _do_generate(messages, options)
    IO->>IO: _coerce_generation_options() + _raise_on_unsupported_options()
    IO->>RM: is_input_safe(messages)
    RM->>RA: action.run(flow, messages)
    RA->>RA: _rail_llm_call_var.set(None)
    RA->>ER: model_call(model_type, prompt)
    ER-->>RA: LLMResponse
    RA->>RA: _rail_llm_call_var.set(RailLLMCall(...))
    RA-->>RM: RailResult(is_safe, ...)
    RM->>RM: get_and_clear_rail_llm_call_contextvar() → RailCallRecord
    RM-->>IO: "RailResult(records=(RailCallRecord,))"
    IO->>IO: records.extend(input_result.records)
    IO->>ER: model_call(main, messages)
    ER-->>IO: LLMResponse
    IO->>IO: _make_generation_record() → records.append(...)
    IO->>RM: is_output_safe(messages, response_text)
    RM-->>IO: "RailResult(records=(RailCallRecord,))"
    IO->>IO: records.extend(output_result.records)
    IO->>LB: _build_generation_log(records, options, duration)
    LB->>LB: _activated_rail() + _call_info() + _build_generation_stats()
    LB-->>IO: GenerationLog
    IO->>IO: _build_generation_response(text, reasoning, response, log)
    IO-->>C: GenerationResponse
Loading

Comments Outside Diff (1)

  1. nemoguardrails/guardrails/iorails.py, line 1258-1263 (link)

    P1 Missing unsupported-options validation on streaming path

    stream_async() calls _coerce_generation_options() but never calls _raise_on_unsupported_options(), so options.state, options.output_vars, and options.log.internal_events/colang_history are silently swallowed instead of raising the ValueError/NotImplementedError that identical inputs produce via generate_async(). A caller that accidentally passes GenerationOptions(output_vars=["x"]) to stream_async() gets no error and no output data, while the same call through generate_async() raises immediately.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: nemoguardrails/guardrails/iorails.py
    Line: 1258-1263
    
    Comment:
    **Missing unsupported-options validation on streaming path**
    
    `stream_async()` calls `_coerce_generation_options()` but never calls `_raise_on_unsupported_options()`, so `options.state`, `options.output_vars`, and `options.log.internal_events`/`colang_history` are silently swallowed instead of raising the `ValueError`/`NotImplementedError` that identical inputs produce via `generate_async()`. A caller that accidentally passes `GenerationOptions(output_vars=["x"])` to `stream_async()` gets no error and no output data, while the same call through `generate_async()` raises immediately.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
nemoguardrails/guardrails/iorails.py:1258-1263
**Missing unsupported-options validation on streaming path**

`stream_async()` calls `_coerce_generation_options()` but never calls `_raise_on_unsupported_options()`, so `options.state`, `options.output_vars`, and `options.log.internal_events`/`colang_history` are silently swallowed instead of raising the `ValueError`/`NotImplementedError` that identical inputs produce via `generate_async()`. A caller that accidentally passes `GenerationOptions(output_vars=["x"])` to `stream_async()` gets no error and no output data, while the same call through `generate_async()` raises immediately.

### Issue 2 of 3
nemoguardrails/guardrails/rails_manager.py:385-393
**Tool-result multi-exchange records dropped**

`_run_tool_result_rail` processes exchanges in a loop and then creates a single `RailCallRecord` outside the loop from the final `result`. For a multi-exchange flow that partially succeeds before breaking on an unsafe result, only the breaking exchange is recorded; the safe intermediate exchanges produce no records. If `options.log.activated_rails` is requested, those exchanges are invisible in the `GenerationLog`. This aligns with the current "one record per flow" design, but it is worth confirming this is intentional rather than an oversight now that logging is supported.

### Issue 3 of 3
nemoguardrails/guardrails/iorails.py:280-299
**Speculative path silently drops main-LLM timing from stats**

`_make_generation_record` is called with `started_at=None`, `finished_at=None`, `duration=None` on the speculative path, so `GenerationStats.generation_rails_duration` is always `None` and `llm_calls_duration` excludes the main call when speculative generation is used. This is documented in the code comment, but the PR description and `GenerationResponse` table don't mention it. Users inspecting stats under `options.log` on a speculative-generation config may see unexpectedly empty timing fields.

Reviews (1): Last reviewed commit: "Revert change to nemoguards config main ..." | Re-trigger Greptile

Comment on lines 385 to 393
result = await action.run(exchange.results, exchange.calls)
if not result.is_safe:
break
# Tool rails are model-free, so no LLM call is captured (usage stays None).
call = get_and_clear_rail_llm_call_contextvar()
result = replace(result, records=(_rail_call_record(flow, "tool_input", result, call),))
mark_rail_stop(span, result.is_safe)
if self._content_capture_enabled:
all_results = [r for exchange in exchanges for r in exchange.results]

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 Tool-result multi-exchange records dropped

_run_tool_result_rail processes exchanges in a loop and then creates a single RailCallRecord outside the loop from the final result. For a multi-exchange flow that partially succeeds before breaking on an unsafe result, only the breaking exchange is recorded; the safe intermediate exchanges produce no records. If options.log.activated_rails is requested, those exchanges are invisible in the GenerationLog. This aligns with the current "one record per flow" design, but it is worth confirming this is intentional rather than an oversight now that logging is supported.

Prompt To Fix With AI
This is a comment left during a code review.
Path: nemoguardrails/guardrails/rails_manager.py
Line: 385-393

Comment:
**Tool-result multi-exchange records dropped**

`_run_tool_result_rail` processes exchanges in a loop and then creates a single `RailCallRecord` outside the loop from the final `result`. For a multi-exchange flow that partially succeeds before breaking on an unsafe result, only the breaking exchange is recorded; the safe intermediate exchanges produce no records. If `options.log.activated_rails` is requested, those exchanges are invisible in the `GenerationLog`. This aligns with the current "one record per flow" design, but it is worth confirming this is intentional rather than an oversight now that logging is supported.

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!

Comment on lines +280 to +299
def _build_generation_stats(
records: list[RailCallRecord], call_records: list[RailCallRecord], total_duration: Optional[float]
) -> GenerationStats:
"""Aggregate per-phase durations and token totals across every recorded call."""

def _phase_duration(*rail_types: str) -> Optional[float]:
total = sum((r.duration or 0.0) for r in records if r.rail_type in rail_types)
return total or None

return GenerationStats(
input_rails_duration=_phase_duration("input", "tool_input"),
output_rails_duration=_phase_duration("output", "tool_output"),
generation_rails_duration=_phase_duration("generation"),
total_duration=total_duration,
llm_calls_duration=sum((r.duration or 0.0) for r in call_records) or None,
llm_calls_count=len(call_records),
llm_calls_total_prompt_tokens=sum(r.usage.input_tokens for r in call_records if r.usage),
llm_calls_total_completion_tokens=sum(r.usage.output_tokens for r in call_records if r.usage),
llm_calls_total_tokens=sum(r.usage.total_tokens for r in call_records if r.usage),
)

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 Speculative path silently drops main-LLM timing from stats

_make_generation_record is called with started_at=None, finished_at=None, duration=None on the speculative path, so GenerationStats.generation_rails_duration is always None and llm_calls_duration excludes the main call when speculative generation is used. This is documented in the code comment, but the PR description and GenerationResponse table don't mention it. Users inspecting stats under options.log on a speculative-generation config may see unexpectedly empty timing fields.

Prompt To Fix With AI
This is a comment left during a code review.
Path: nemoguardrails/guardrails/iorails.py
Line: 280-299

Comment:
**Speculative path silently drops main-LLM timing from stats**

`_make_generation_record` is called with `started_at=None`, `finished_at=None`, `duration=None` on the speculative path, so `GenerationStats.generation_rails_duration` is always `None` and `llm_calls_duration` excludes the main call when speculative generation is used. This is documented in the code comment, but the PR description and `GenerationResponse` table don't mention it. Users inspecting stats under `options.log` on a speculative-generation config may see unexpectedly empty timing fields.

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!

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
nemoguardrails/guardrails/iorails.py (1)

958-1067: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Capture speculative generation before applying the input-rail verdict.

When generation finishes first and input rails later block, Lines 1051-1058 return without appending the completed main call at Line 1067. Simultaneous completion in the rails-first branch has the same omission. This undercounts calls, tokens, and cost on blocked requests; successful speculative calls also lose timing. Time and record the generation task at its execution boundary, regardless of whether its response is ultimately returned.

🤖 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/iorails.py` around lines 958 - 1067, Update
_parallel_input_rail_and_response_generation to capture and record the completed
generation task before applying the input-rail verdict, including both
generation-first and simultaneous-completion paths. Time the main LLM call at
its execution boundary and append its generation record with usage, model,
timestamps, duration, provider, and main_prompt even when input rails later
reject the response; avoid recording the call twice.
nemoguardrails/guardrails/rails_manager.py (1)

438-461: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Process every completed task before returning an unsafe result.

asyncio.wait can place multiple tasks in done. If the first sorted result is unsafe, Line 460 returns before collecting records—or retrieving exceptions—from the remaining completed tasks. This violates the “every rail that ran” log contract. Consume the full done batch, select the first unsafe result, then cancel only pending tasks.

🤖 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/rails_manager.py` around lines 438 - 461, Update
the task-processing loop in the rail execution method to process every task in
each `done` batch, collecting records and retrieving results or exceptions
before deciding to return. Track the first unsafe result encountered in sorted
task order, then cancel and await only `pending_tasks`; return that unsafe
result with all collected records after the batch is fully consumed.
🧹 Nitpick comments (1)
tests/guardrails/test_guardrails.py (1)

126-127: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a non-None options forwarding test.

These assertions only cover the default path. Add one sync/async wrapper test with a sentinel GenerationOptions or dict and assert that the same object is passed to the underlying engine, preventing regressions that accept but drop the new argument.

🤖 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 `@tests/guardrails/test_guardrails.py` around lines 126 - 127, Add a test
covering non-None options for both sync and async guardrail wrappers, using a
sentinel GenerationOptions instance or dict. Invoke the wrappers with that same
object and assert rails_engine.generate and generate_async each receive it
unchanged alongside messages, while preserving the existing default-path
assertions.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@nemoguardrails/guardrails/guardrails_types.py`:
- Around line 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.

In `@nemoguardrails/guardrails/guardrails.py`:
- Around line 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].

In `@nemoguardrails/guardrails/rail_action.py`:
- Around line 197-221: Update both LLM-call helpers surrounding
EngineRegistry.model_call and the corresponding helper at the referenced later
section to record attempted calls when the await raises. In each exception path,
capture elapsed timing and populate _rail_llm_call_var with the available
request/model/provider and failure-safe metadata, then re-raise the original
exception so existing error handling remains unchanged. Preserve the current
successful-response recording behavior.

---

Outside diff comments:
In `@nemoguardrails/guardrails/iorails.py`:
- Around line 958-1067: Update _parallel_input_rail_and_response_generation to
capture and record the completed generation task before applying the input-rail
verdict, including both generation-first and simultaneous-completion paths. Time
the main LLM call at its execution boundary and append its generation record
with usage, model, timestamps, duration, provider, and main_prompt even when
input rails later reject the response; avoid recording the call twice.

In `@nemoguardrails/guardrails/rails_manager.py`:
- Around line 438-461: Update the task-processing loop in the rail execution
method to process every task in each `done` batch, collecting records and
retrieving results or exceptions before deciding to return. Track the first
unsafe result encountered in sorted task order, then cancel and await only
`pending_tasks`; return that unsafe result with all collected records after the
batch is fully consumed.

---

Nitpick comments:
In `@tests/guardrails/test_guardrails.py`:
- Around line 126-127: Add a test covering non-None options for both sync and
async guardrail wrappers, using a sentinel GenerationOptions instance or dict.
Invoke the wrappers with that same object and assert rails_engine.generate and
generate_async each receive it unchanged alongside messages, while preserving
the existing default-path assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 0f428c31-8892-4e50-a8d1-5398aa3ee1be

📥 Commits

Reviewing files that changed from the base of the PR and between 450594a and cdee6c3.

📒 Files selected for processing (19)
  • nemoguardrails/base_guardrails.py
  • nemoguardrails/guardrails/actions/content_safety_action.py
  • nemoguardrails/guardrails/actions/jailbreak_detection_action.py
  • nemoguardrails/guardrails/actions/topic_safety_action.py
  • nemoguardrails/guardrails/engine_registry.py
  • nemoguardrails/guardrails/guardrails.py
  • nemoguardrails/guardrails/guardrails_types.py
  • nemoguardrails/guardrails/iorails.py
  • nemoguardrails/guardrails/rail_action.py
  • nemoguardrails/guardrails/rails_manager.py
  • tests/guardrails/test_guardrails.py
  • tests/guardrails/test_iorails.py
  • tests/guardrails/test_iorails_generation_log.py
  • tests/guardrails/test_iorails_generation_log_capture.py
  • tests/guardrails/test_iorails_generation_response.py
  • tests/guardrails/test_jailbreak_detection_iorails_actions.py
  • tests/guardrails/test_rails_manager.py
  • tests/guardrails/test_tool_rails_iorails.py
  • tests/guardrails/test_topic_safety_iorails_actions.py

Comment on lines +134 to +141
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)

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

Comment on lines 221 to 255
@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]: ...

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].

Comment on lines +197 to +221
"""Call an LLM via EngineRegistry and return the structured response.

Captures usage/model/timing into a request-scoped contextvar so RailsManager can
record this call in the GenerationLog after the rail runs.
"""
if not model_type:
raise RuntimeError("model_type is required for LLM calls")
return await self.engine_registry.model_call(model_type, messages, **kwargs)
started_at = time.time()
t0 = time.monotonic()
response = await self.engine_registry.model_call(model_type, messages, **kwargs)
duration = time.monotonic() - t0
finished_at = time.time()
_rail_llm_call_var.set(
RailLLMCall(
usage=response.usage,
llm_model_name=response.model,
request_id=response.request_id,
provider_name=self.engine_registry.provider_name(model_type),
prompt=serialize_prompt(messages),
completion=response.content,
started_at=started_at,
finished_at=finished_at,
duration=duration,
)
)

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

Record attempted model and API calls when they fail.

Both helpers populate _rail_llm_call_var only after the await succeeds. If the external call raises, run() returns an unsafe result but RailsManager records made_call=False, causing the generated log and call count to claim no call occurred. Capture the attempt and elapsed timing in an exception path before re-raising.

Also applies to: 224-254

🤖 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/rail_action.py` around lines 197 - 221, Update both
LLM-call helpers surrounding EngineRegistry.model_call and the corresponding
helper at the referenced later section to record attempted calls when the await
raises. In each exception path, capture elapsed timing and populate
_rail_llm_call_var with the available request/model/provider and failure-safe
metadata, then re-raise the original exception so existing error handling
remains unchanged. Preserve the current successful-response recording behavior.

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR wires up the GenerationResponse structured return type for IORails' non-streaming generate() and generate_async() methods, matching the parity contract that LLMRails already provides. When options is passed, IORails now returns a GenerationResponse populated with response, reasoning_content, tool_calls (canonical dict shape), llm_metadata, and a synthesized GenerationLog; without options the existing bare LLMMessage dict is returned unchanged.

  • The log-synthesis pipeline is new: a RailCallRecord is attached to every RailResult via a ContextVar-captured RailLLMCall in RailAction; _do_generate accumulates all records and feeds them to _build_generation_log, which produces activated_rails, llm_calls, and aggregate stats from the same data.
  • Guardrails.generate/generate_async promotes options from a **kwargs passthrough to an explicit named parameter, and unsupported option combinations (state, output_vars, log.internal_events, log.colang_history) raise immediately rather than silently returning empty data.
  • Three new test files (generation response, generation log, log capture) provide broad unit coverage; the speculative path correctly documents that per-call LLM timing is unavailable when racing input rails against generation.

Confidence Score: 4/5

Safe to merge; the safety-verdict logic is unchanged, the new log-collection path is additive and gated behind the options argument, and the unit test suite passes 5431 tests.

The core pipeline is well-structured: ContextVar isolation is correct for both sequential and parallel rail execution, record accumulation flows cleanly through RailResult, and all three blocking points carry a partial log. Two minor rough edges: jailbreak detection stores a raw bool as return_value while the other two action types store dicts, creating an inconsistent schema in ExecutedAction.return_value for log consumers; and the speculative-generation path intentionally omits the main LLM call duration from the per-call breakdown, meaning llm_calls_duration in GenerationStats is systematically lower than total_duration for any user running speculative mode with options.log enabled.

nemoguardrails/guardrails/actions/jailbreak_detection_action.py (return_value type inconsistency) and nemoguardrails/guardrails/iorails.py speculative path (missing main LLM timing in llm_calls_duration).

Important Files Changed

Filename Overview
nemoguardrails/guardrails/iorails.py Core change: adds GenerationResponse support to generate_async/generate; introduces record collection pipeline, per-phase log synthesis, and refusal shaping for both return contracts. The speculative path correctly omits per-call timing (documented) but this causes llm_calls_duration to be underreported.
nemoguardrails/guardrails/rail_action.py Adds RailLLMCall dataclass and _rail_llm_call_var ContextVar to capture LLM/API call metadata per rail; ContextVar is cleared at the start of each rail run and set after the call, correctly isolated per asyncio Task for parallel rails.
nemoguardrails/guardrails/rails_manager.py Adds _rail_call_record builder and wires record collection into _run_rail, _run_tool_call_rail, _run_tool_result_rail, and both sequential/parallel runners; records propagate correctly through collected lists and are merged into the final RailResult.
nemoguardrails/guardrails/guardrails_types.py Introduces frozen RailCallRecord dataclass; records/return_value on RailResult are correctly excluded from equality/hashing via compare=False; serialize_prompt helper is straightforward.
nemoguardrails/guardrails/guardrails.py Promotes options from **kwargs passthrough to an explicit named parameter on generate/generate_async; overload signatures and delegation to rails_engine are updated consistently.
nemoguardrails/guardrails/actions/jailbreak_detection_action.py Adds return_value to RailResult, but uses a raw bool (the jailbreak field value) rather than a structured dict like content_safety ({allowed, policy_violations}) and topic_safety ({on_topic}), creating a type inconsistency in ExecutedAction.return_value across rail types.
nemoguardrails/guardrails/actions/content_safety_action.py Consistently adds structured return_value dicts ({allowed, policy_violations}) for both safe and unsafe outcomes.
nemoguardrails/guardrails/actions/topic_safety_action.py Adds structured {on_topic: bool} return_value to both safe and unsafe RailResult paths.
nemoguardrails/guardrails/engine_registry.py Adds provider_name() helper that delegates to _get_engine; straightforward and correctly typed.
tests/guardrails/test_iorails_generation_response.py New test file; good coverage of the structured vs bare return trigger, response/reasoning/tool_call fields, refusal shaping, and unsupported-option validation (state, output_vars, internal_events).
tests/guardrails/test_iorails_generation_log.py New test file covering GenerationLog synthesis: activated_rails, llm_calls, stats, token aggregation, and blocking-rail partial logs.
tests/guardrails/test_iorails_generation_log_capture.py New test file exercising log capture with real RailCallRecord construction through the full pipeline.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Caller
    participant GR as Guardrails.generate_async
    participant IO as IORails._do_generate
    participant RM as RailsManager
    participant RA as RailAction._get_llm_response
    participant CV as _rail_llm_call_var (ContextVar)
    participant ER as EngineRegistry

    C->>GR: generate_async(messages, options)
    GR->>IO: "_do_generate(messages, options=options)"
    IO->>IO: _coerce_generation_options(options)
    IO->>IO: _raise_on_unsupported_options(options, state)
    IO->>IO: "records = []"

    IO->>RM: are_tool_results_safe(messages)
    RM->>RA: action.run()
    RA->>CV: set(None)
    RA->>ER: model_call / api_call
    RA->>CV: set(RailLLMCall(usage, timing, model))
    RA-->>RM: "RailResult(is_safe=True)"
    RM->>CV: get_and_clear()
    RM-->>IO: "RailResult(records=(RailCallRecord,))"
    IO->>IO: records.extend(tool_result.records)

    IO->>RM: is_input_safe(messages)
    RM->>RA: action.run() [per input rail]
    RA->>CV: set(RailLLMCall(...))
    RM->>CV: get_and_clear()
    RM-->>IO: "RailResult(records=(RailCallRecord,...))"
    IO->>IO: records.extend(input_result.records)

    IO->>ER: model_call(main, messages)
    IO->>IO: records.append(_make_generation_record(...))

    IO->>RM: is_output_safe(messages, response_text)
    RM->>RA: action.run() [per output rail]
    RM-->>IO: "RailResult(records=(RailCallRecord,...))"
    IO->>IO: records.extend(output_result.records)

    alt has_generation_options
        IO->>IO: "log = _build_generation_log(records, options, total_duration)"
        IO->>IO: _build_generation_response(text, reasoning, response, log)
        IO-->>C: GenerationResponse
    else bare path
        IO-->>C: LLMMessage dict
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Caller
    participant GR as Guardrails.generate_async
    participant IO as IORails._do_generate
    participant RM as RailsManager
    participant RA as RailAction._get_llm_response
    participant CV as _rail_llm_call_var (ContextVar)
    participant ER as EngineRegistry

    C->>GR: generate_async(messages, options)
    GR->>IO: "_do_generate(messages, options=options)"
    IO->>IO: _coerce_generation_options(options)
    IO->>IO: _raise_on_unsupported_options(options, state)
    IO->>IO: "records = []"

    IO->>RM: are_tool_results_safe(messages)
    RM->>RA: action.run()
    RA->>CV: set(None)
    RA->>ER: model_call / api_call
    RA->>CV: set(RailLLMCall(usage, timing, model))
    RA-->>RM: "RailResult(is_safe=True)"
    RM->>CV: get_and_clear()
    RM-->>IO: "RailResult(records=(RailCallRecord,))"
    IO->>IO: records.extend(tool_result.records)

    IO->>RM: is_input_safe(messages)
    RM->>RA: action.run() [per input rail]
    RA->>CV: set(RailLLMCall(...))
    RM->>CV: get_and_clear()
    RM-->>IO: "RailResult(records=(RailCallRecord,...))"
    IO->>IO: records.extend(input_result.records)

    IO->>ER: model_call(main, messages)
    IO->>IO: records.append(_make_generation_record(...))

    IO->>RM: is_output_safe(messages, response_text)
    RM->>RA: action.run() [per output rail]
    RM-->>IO: "RailResult(records=(RailCallRecord,...))"
    IO->>IO: records.extend(output_result.records)

    alt has_generation_options
        IO->>IO: "log = _build_generation_log(records, options, total_duration)"
        IO->>IO: _build_generation_response(text, reasoning, response, log)
        IO-->>C: GenerationResponse
    else bare path
        IO-->>C: LLMMessage dict
    end
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
nemoguardrails/guardrails/actions/jailbreak_detection_action.py:44-47
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)
```

### Issue 2 of 2
nemoguardrails/guardrails/iorails.py:1060-1064
**Speculative path: main LLM duration absent from `llm_calls_duration`**

`_make_generation_record` is called with `None, None, None` for `started_at`, `finished_at`, and `duration`. In `_build_generation_stats`, `llm_calls_duration` is computed as `sum(r.duration or 0.0 for r in call_records)`, so the main LLM call contributes exactly `0.0` when speculative mode is active. A user who enables speculative generation and requests `options.log` will see `llm_calls_duration` that is always lower than the true value by the main inference time. `total_duration` is correct (it comes from `t_start`), so the discrepancy is visible to users comparing the two stats fields.

Reviews (2): Last reviewed commit: "Revert change to nemoguards config main ..." | Re-trigger Greptile

Comment on lines +44 to +47
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)

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!

Comment on lines 1060 to +1064
set_speculative_span_attrs(request_span, first_completed, "none")

log.debug("[%s] Main LLM response: %s", req_id, truncate(response.content))
# Speculative races the main call, so per-call timing isn't tracked here; the
# generation record carries usage/model without timestamps or a duration.

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 Speculative path: main LLM duration absent from llm_calls_duration

_make_generation_record is called with None, None, None for started_at, finished_at, and duration. In _build_generation_stats, llm_calls_duration is computed as sum(r.duration or 0.0 for r in call_records), so the main LLM call contributes exactly 0.0 when speculative mode is active. A user who enables speculative generation and requests options.log will see llm_calls_duration that is always lower than the true value by the main inference time. total_duration is correct (it comes from t_start), so the discrepancy is visible to users comparing the two stats fields.

Prompt To Fix With AI
This is a comment left during a code review.
Path: nemoguardrails/guardrails/iorails.py
Line: 1060-1064

Comment:
**Speculative path: main LLM duration absent from `llm_calls_duration`**

`_make_generation_record` is called with `None, None, None` for `started_at`, `finished_at`, and `duration`. In `_build_generation_stats`, `llm_calls_duration` is computed as `sum(r.duration or 0.0 for r in call_records)`, so the main LLM call contributes exactly `0.0` when speculative mode is active. A user who enables speculative generation and requests `options.log` will see `llm_calls_duration` that is always lower than the true value by the main inference time. `total_duration` is correct (it comes from `t_start`), so the discrepancy is visible to users comparing the two stats fields.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size: XL status: triaged Triaged by a maintainer; eligible for automated review (CodeRabbit/Greptile).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant