Skip to content

refactor(generation): decompose LLMGenerationActions into single-responsibility helpers#2168

Open
Pouyanpi wants to merge 19 commits into
developfrom
pouyanpi/improve-generate-colang
Open

refactor(generation): decompose LLMGenerationActions into single-responsibility helpers#2168
Pouyanpi wants to merge 19 commits into
developfrom
pouyanpi/improve-generate-colang

Conversation

@Pouyanpi

@Pouyanpi Pouyanpi commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Description

Decompose LLMGenerationActions (the Colang 1.0 dialog generation actions in
nemoguardrails/actions/llm/generation.py).

Today generate_user_intent, generate_bot_message and
generate_intent_steps_message each mix several responsibilities, and the same
Task.GENERAL "general response" logic is duplicated across four call sites with
subtle divergences. The result is that you cannot tell, from a config alone,
which code path a request will take (dialog vs single-call vs passthrough vs
general), and the passthrough/tool-call path is easy to break silently.

This PR pins the observable behavior first, then decomposes behind that safety
net:

  • Behavioral regression suite added first (tests/test_generation_equivalence.py).
    It captures four channels per scenario that an event-only check would miss:
    the emitted internal-event sequence, the per-call LLMCallInfo.task, the
    stop argument / stream mode (via a recording fake LLM), and the streamed
    chunk sequence. This is the gate every later commit keeps green.
  • Structural decomposition (behavior-preserving): a shared
    _generate_general_response helper for the four Task.GENERAL sites, a split
    of intent detection from the general bot turn (_emit_general_bot_turn), a
    typed single-call cache seam, and a _StreamingHandoffRegistry that
    encapsulates the <<STREAMING[uid]>> handoff.
  • Targeted behavior fixes (this is refactor plus fixes, not a pure
    refactor): normalize multimodal user input in the single-call path; evict
    streaming-handoff handlers when consumed (fixes a module-lifetime leak);
    converge the single-call no-user-messages general turn onto the multi-call
    path (it now emits BotThinking / tool calls / passthrough output and uses
    stop=["User:"]); and remove genuinely dead branches.

This also unblocks per-request OpenAI tools support for non-passthrough configs
(#2057): the extracted _generate_general_response / _emit_general_bot_turn
seams are exactly what that feature builds on.

The change is a curated, gradual commit series (harness first, then one
transformation per commit) intended to be reviewed commit-by-commit.

Related Issue(s)

AI Assistance

  • No AI tools were used.
  • AI tools were used; a human reviewed and can explain every change.

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

  • Bug Fixes

    • Improved consistency across single-call and multi-step response generation.
    • Enhanced streaming handoffs so response content flows reliably between generation stages.
    • Improved handling of cached responses, multimodal inputs, passthrough behavior, reasoning traces, and fallback intent detection.
    • Preserved expected behavior for value generation and safe response parsing.
  • Tests

    • Added comprehensive coverage for dialog generation, streaming, embeddings-based intent detection, passthrough flows, reasoning traces, and cached responses.

@Pouyanpi Pouyanpi added this to the v0.24.0 milestone Jul 13, 2026
@Pouyanpi Pouyanpi self-assigned this Jul 13, 2026
@github-actions github-actions Bot added size: XL status: needs triage New issues that have not yet been reviewed or categorized. labels Jul 13, 2026
Pouyanpi added 14 commits July 13, 2026 19:27
Phase 0 of the generation.py decomposition: a four-channel characterization
gate that pins the observable behavior of generate_user_intent,
generate_next_steps, generate_bot_message, generate_value and
generate_intent_steps_message before any refactor.

Channels: emitted internal-event sequence and per-call LLMCallInfo.task (from
GenerationOptions log), the stop argument and stream mode (RecordingFakeLLM,
the only channel that distinguishes the four Task.GENERAL call sites), and the
streamed chunk sequence (stream_async). Covers dialog multi-call, embeddings
-only, single-call (incl. the <<STREAMING[uid]>> handoff), passthrough/general,
context-var bot intent, reasoning-trace packaging, and generate_value.

Two findings from building this: safe_eval's error path is
effectively unreachable, and the general-path streaming push emits no duplicate
visible chunk.
Phase 1 of the LLMGenerationActions decomposition. The four Task.GENERAL
LLM-call sites (the general user-intent fallback, the passthrough completion,
the single-call general branch, and the passthrough bot-message branch)
duplicated the same LLMCallInfo-set + llm_call + parse core. Extract it into a
private _generate_general_response parameterized by prompt, stop,
stream_during_call, llm_call_task and parse_task so each site keeps its exact
behavior (including the passthrough bot-message branch reporting
GENERATE_BOT_MESSAGE while parsing as GENERAL).

Prompt rendering, chunk retrieval and output stripping stay at the call sites.
Also extract the duplicated passthrough-fn return unpacking into
_unpack_passthrough_output.

Behavior preserved: the Phase 0 equivalence suite and full test suite pass
unchanged.
Phase 2 of the LLMGenerationActions decomposition. generate_user_intent
returned a UserIntent with user messages but a BotMessage/BotToolCalls turn
without them; that polymorphism was inlined in one large if/else. Split it into
two private helpers so the action reads as a dispatcher:

- _detect_user_intent: the canonical-form path (embeddings-only lookup plus
  LLM-based intent generation).
- _emit_general_bot_turn: the no-user-messages path (passthrough with/without a
  passthrough fn and the non-passthrough general path), including the
  push/reasoning-trace/tool-calls/BotMessage packaging and passthrough_output
  context event.

generate_intent_steps_message's else-branch is intentionally NOT folded into
_emit_general_bot_turn: its current behavior is a reduced subset (it does not
emit BotThinking, BotToolCalls, or handle passthrough), so sharing the full
helper would change behavior. Converging the two is left as a follow-up.

Removed a redundant streaming_handler re-fetch that the parameterization made
dead. Behavior preserved: the Phase 0 equivalence suite and full suite pass.
Phase 3 of the LLMGenerationActions decomposition. The single-call path passed
its precomputed bot intent/message events between actions via free-form
event["additional_info"]["bot_intent_event" | "bot_message_event"] dict access,
an implicit cross-action contract.

Introduce build_single_call_payload (producer, in generate_intent_steps_message)
and read_single_call_payload returning a SingleCallPayload dataclass (consumers,
in generate_next_steps and generate_bot_message) so the contract is explicit and
typed. The wire format is unchanged: build still emits the same plain nested
additional_info dict, so the event serializes byte-identically; the dataclass is
only a transient read view. KeyError on a missing payload is preserved (guarding
it is a follow-up).

Behavior preserved: the Phase 0 equivalence suite and full suite pass.
Phase 4 of the LLMGenerationActions decomposition. The single-call streaming
path coordinated with the bot-message phase through a module-global dict
(local_streaming_handlers) and a hand-built '<<STREAMING[uid]>>' marker parsed
with magic offsets (text[26:-4]).

Wrap both behind _StreamingHandoffRegistry (register -> marker text,
parse_marker -> uid|None, get -> handler), so the marker format and the handler
store live in one place. Handlers are still kept for the module lifetime; the
get accessor does not evict, preserving the existing handler leak (cleanup is a
follow-up).

Unify the two divergent set_pattern rules into _streaming_pattern_for: the
include_bot_message_parser flag preserves generate_bot_message treating both
verbose_v1 and bot_message as the verbose pattern while
generate_intent_steps_message treats only verbose_v1 that way.

A cast (no runtime effect) resolves a pre-existing Optional-handler type gap that
the registry's typed accessor surfaced. Behavior preserved: the Phase 0
equivalence suite, streaming suites, and full suite pass.
…tion

Follow-up cleanup. Two unreachable branches:

- In _detect_user_intent, the `if user_intent is None` check after the
  preceding `user_intent = "unknown message"` fallback could never be true.
- In generate_next_steps, next_step is only ever {"bot": ...}, so the
  `next_step.get("execute")` branch (and the next_step dict itself) was dead.

Behavior-preserving: the equivalence suite and full suite pass unchanged.
generate_intent_steps_message passed event["text"] straight to the user-message
index search and KB lookup. For multimodal input event["text"] is a list of
content parts, not a string, so the single-call path mishandled it while
generate_user_intent already normalized it.

Join the text parts to a string up front, mirroring generate_user_intent, and
use it for both the index search and the KB lookup. Adds an equivalence
scenario for multimodal (list) content in single-call mode.
The single-call streaming handoff stored each inner StreamingHandler in a
module-level registry but never removed it, so handlers accumulated for the
process lifetime (a slow leak under sustained single-call streaming).

Rename the registry accessor get -> take and pop the handler on retrieval; it is
consumed exactly once by generate_bot_message, so eviction is safe. A unit test
covers the marker round-trip and that a handler is gone after being taken.
…l path

When single_call is enabled but there are no user messages,
generate_intent_steps_message took a reduced general-response path: it rendered
the GENERAL prompt with no context, called the LLM with no stop sequence, and
emitted only a BotMessage. It dropped reasoning traces (BotThinking), tool calls
(BotToolCalls), and passthrough handling that generate_user_intent's general
path produces.

Thread context into generate_intent_steps_message and delegate its else-branch
to the shared _emit_general_bot_turn, so the no-user-messages general turn is
identical regardless of single_call. Observable changes in this mode: the LLM
call now uses stop=["User:"], and reasoning traces / tool calls / passthrough
output are emitted. The rendered prompt is unchanged when relevant_chunks is
empty.

Updates the equivalence suite: the single-call general scenario now asserts the
converged stop, and a new test covers reasoning-trace emission in this mode.
generate_bot_message repeated the same "optional BotThinking from the reasoning
trace, then the final event" packaging in four return paths (the two single-call
cache branches and the two final bot-message branches). Extract it into a
module-level _bot_turn_output_events helper.

The context_updates parameter preserves the existing difference: the generated
bot-message path records bot_thinking on the context, the single-call cached
path does not. Pure refactor; the full test suite passes unchanged.
_extract_user_message_example and _extract_bot_message_example repeated the same
`asdict(spec_op.spec) if isinstance(spec_op.spec, Spec) else cast(Dict, ...)`
normalization (the latter carried a TODO about the duplication). Extract it into
a module-level _spec_op_as_dict helper. Pure refactor; full suite passes.
…message

Move the single-call cache-consumption block (the streaming handoff and the
direct cached-message paths) into _bot_message_from_single_call_cache, returning
None to signal "fall back to regeneration".

The `event` rebinding to the last user-intent event stays in generate_bot_message
(with the None check that narrows it), preserving the existing fall-through
behavior where the non-passthrough branch reads that rebound event. Pure
refactor; the equivalence suite and full suite pass.
generate_intent_steps_message inlined ~80 lines of deeply nested logic to build
the few-shot examples (search the user-message index, pair each candidate intent
with a flow and its bot message). Extract it into _build_intent_steps_examples,
returning (examples, potential_user_intents). Pure refactor; the equivalence
suite and full suite pass.
Use explicit is-not-None guards instead of the (opts and opts.llm_params) or {}
idiom, which ty types as GenerationOptions | dict and rejects against the
_generate_general_response llm_params: Optional[dict] parameter. Behavior is
unchanged and matches the existing guard used in the generate_value path.
@Pouyanpi
Pouyanpi force-pushed the pouyanpi/improve-generate-colang branch from b31fe12 to 5f4c248 Compare July 13, 2026 17:38
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.18605% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
nemoguardrails/actions/llm/generation.py 94.18% 15 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Pouyanpi
Pouyanpi marked this pull request as ready for review July 14, 2026 06:02
@Pouyanpi Pouyanpi 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 14, 2026
@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR decomposes the Colang 1.0 dialog generation actions into smaller helpers. The main changes are:

  • Shared general-response generation for dialog and passthrough paths.
  • Typed single-call cache helpers for bot intent and bot message reuse.
  • Streaming handoff registration and cleanup for single-call streaming.
  • Regression coverage for event output, LLM call metadata, stops, and streamed chunks.

Confidence Score: 5/5

This looks safe to merge.

  • The single-call cache miss path now keeps the current bot intent for fallback generation.
  • No blocking issue was found in the changed fix path.

Important Files Changed

Filename Overview
nemoguardrails/actions/llm/generation.py Refactors dialog generation into helpers and updates single-call cache fallback behavior.
tests/test_generation_equivalence.py Adds behavioral coverage for generation events, call metadata, stop arguments, and streaming.

Reviews (5): Last reviewed commit: "test(recorded): add dialog bot-message g..." | Re-trigger Greptile

Comment thread nemoguardrails/actions/llm/generation.py Outdated
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

LLM generation actions now use shared response, intent, bot-turn, payload, and streaming-handoff helpers. Single-call and general paths are covered by a new behavioral equivalence test harness spanning dialog, embeddings, streaming, passthrough, reasoning, and value generation.

Changes

LLM generation refactor

Layer / File(s) Summary
Generation infrastructure and cached payloads
nemoguardrails/actions/llm/generation.py
Adds cached single-call payload helpers and a registry for marker-based streaming handoffs.
Shared intent and bot-turn generation
nemoguardrails/actions/llm/generation.py
Centralizes general LLM responses, intent detection, passthrough handling, streaming patterns, and bot-turn event assembly.
Single-call intent and bot-message flow
nemoguardrails/actions/llm/generation.py
Extracts intent-step example construction and moves cached bot-message validation and streaming handoff logic into dedicated helpers.
Behavioral equivalence coverage
tests/test_generation_equivalence.py
Adds recorded LLM-call assertions and tests for dialog pipelines, embeddings-only detection, single-call generation, streaming, passthrough, reasoning traces, and value generation.

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

Sequence Diagram(s)

sequenceDiagram
  participant IntentSteps as generate_intent_steps_message
  participant Registry as _StreamingHandoffRegistry
  participant BotMessage as generate_bot_message
  IntentSteps->>Registry: register inner streaming handler
  IntentSteps->>BotMessage: cache encoded streaming marker
  BotMessage->>Registry: parse and take handler
  Registry->>BotMessage: pipe inner stream to main handler
Loading

Suggested reviewers: tgasser-nv

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the refactor to decompose LLMGenerationActions into helpers.
Linked Issues check ✅ Passed The refactor splits responsibilities, centralizes shared logic, adds types/tests, and preserves behavior as requested in #1149.
Out of Scope Changes check ✅ Passed The added tests and fixes support the refactor goals and do not appear unrelated to the issue scope.
Docstring Coverage ✅ Passed Docstring coverage is 83.05% which is sufficient. The required threshold is 80.00%.
Test Results For Major Changes ✅ Passed PASS: The description explicitly documents a new behavioral regression suite and notes verification beyond CI / unchecked tests, satisfying the testing-info requirement for this major refactor.
✨ 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 pouyanpi/improve-generate-colang

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

@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: 5

🤖 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/actions/llm/generation.py`:
- Around line 990-998: Update the non-passthrough cache-miss fallthrough in
generate_bot_message to search bot_message_index using the preserved bot_intent
value instead of the rebound event["intent"]. Keep the UserIntent event binding
for _bot_message_from_single_call_cache unchanged.
- Around line 886-937: The cached bot intent mismatch path in
_bot_message_from_single_call_cache must consume any embedded streaming handoff
before falling back. Parse bot_message_event["text"] and call
_streaming_handoff.take() when a marker exists before returning None, while
preserving the existing match validation and normal streaming behavior.

In `@tests/test_generation_equivalence.py`:
- Around line 408-440: Add a non-text multimodal part, specifically an image_url
item, alongside the existing text item in the messages passed to
chat.app.generate within test_single_call_multimodal_input. Keep the text
content and existing assertions unchanged so the test verifies image parts are
ignored while text normalization still produces the same intent and response
sequence.
- Around line 121-122: Add a single-call general-path case to the generation
equivalence tests and assert the complete emitted BotToolCalls payload,
including each tool call’s ID, function name, and arguments, rather than only
the count produced by the event_type == "BotToolCalls" handling. Keep
passthrough coverage intact and verify the public response shape exactly.
- Around line 500-516: Extend
test_streaming_handoff_registry_round_trip_and_eviction to cover malformed
streaming markers, including a marker with the expected prefix but missing or
trailing suffix, and assert _StreamingHandoffRegistry.parse_marker() returns
None for each. Preserve the existing valid round-trip, non-marker, and eviction
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: bbda0e00-200f-4c74-b33d-d3e70a427b27

📥 Commits

Reviewing files that changed from the base of the PR and between c077dbe and 5f4c248.

📒 Files selected for processing (2)
  • nemoguardrails/actions/llm/generation.py
  • tests/test_generation_equivalence.py

Comment thread nemoguardrails/actions/llm/generation.py
Comment thread nemoguardrails/actions/llm/generation.py Outdated
Comment thread tests/test_generation_equivalence.py
Comment thread tests/test_generation_equivalence.py Outdated
Comment thread tests/test_generation_equivalence.py Outdated
Pouyanpi added 2 commits July 14, 2026 08:40
…treaming handoff

- generate_bot_message: on a single-call cache miss, search bot messages with the
  bot intent instead of the rebound user intent.
- _bot_message_from_single_call_cache: evict an embedded streaming handoff on the
  cache-miss fall-through so the registered handler is not leaked.
- _StreamingHandoffRegistry.parse_marker: require the closing suffix so malformed
  markers are rejected instead of returning a bad uid.

Tests: reorganize the equivalence suite into classes and cover the single-call
cache miss (search intent + handoff eviction), multimodal non-text parts, the
full BotToolCalls payload, and malformed streaming markers.
generate_bot_message only released a single-call streaming handoff on the cache
path; the predefined-message and $context_var branches returned first, leaking
the registry entry (and stranding a background llm_call). Add
_discard_single_call_handoff on both bypass branches so the handler is released
regardless of which branch phase 3 takes, restoring the registry invariant.
Pouyanpi added 3 commits July 14, 2026 11:45
- test the streaming-handoff release on the predefined-message bypass path
- add call_prompts assertions to the dialog and single-call tests so a dropped
  render-context key is no longer hidden by the prompt-independent fake
- add direct contract tests for _streaming_pattern_for, _bot_turn_output_events
  and _build_intent_steps_examples (prompt-only, unobservable end-to-end)
- cover generate_next_steps single-step cleanup, the passthrough raw_llm_request
  list branch, the _generate_general_response task-vs-parse divergence, and the
  non-single-call multimodal path
…anches

- TestSingleCall: the single-call cache returns None (falls back) when the last
  user-intent event is a UserMessage rather than a UserIntent
- TestGeneralAndPassthrough: user_messages + passthrough drives
  generate_bot_message's passthrough branch, which reports the
  generate_bot_message task while parsing GENERAL

The multi-step parse-error reduce loop is left uncovered by design: Colang 1.0's
parser is lenient enough that the parse failure almost never fires.
A dialog_generation recorded config whose intent maps (via a flow) to a bot
intent with no predefined message, so generate_bot_message calls the LLM with
few-shot examples + retrieved kb chunks. VCR matches on recorded_body, so the
cassette replays only if all three dialog prompts (intent, next-step,
bot-message) are byte-identical -- a prompt-fidelity equivalence check the
prompt-independent FakeLLMModel oracle cannot make.
@Pouyanpi
Pouyanpi force-pushed the pouyanpi/improve-generate-colang branch from 7b7d706 to 28f0440 Compare July 14, 2026 13:57
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.

refactor: Split out LLMGenerationActions

1 participant