Skip to content

Commit 57d277e

Browse files
Alexander230RobGeada
authored andcommitted
fix(streaming): don't reuse stale across output-rail chunks (NVIDIA-NeMo#1935) (NVIDIA-NeMo#1943)
Signed-off-by: Aleksandr Popov <alexander230r@gmail.com>
1 parent b629ce4 commit 57d277e

2 files changed

Lines changed: 119 additions & 1 deletion

File tree

nemoguardrails/rails/llm/utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ def get_action_details_from_flow_id(
9494
and "execute" in element["_source_mapping"]["line_text"]
9595
and "action_name" in element
9696
):
97-
return element["action_name"], element["action_params"]
97+
# Return a copy: action_params belongs to the shared flow config and
98+
# callers may resolve $bot_message / $user_message placeholders into it.
99+
return element["action_name"], dict(element["action_params"] or {})
98100

99101
raise ValueError(f"No run_action element found for flow_id: {flow_id}")

tests/test_streaming_output_rails.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,3 +542,119 @@ async def self_check_output(**kwargs):
542542
tokens.append(token)
543543

544544
assert "".join(tokens) == "This is a complete response in a single chunk."
545+
546+
547+
@pytest.mark.asyncio
548+
async def test_streaming_output_rails_no_stale_substituted_param():
549+
"""Output rails that take the bot message as a substituted kwarg
550+
(text=$bot_message, as privateai / prompt_security / regex rails do) see
551+
each streamed chunk's own text, not a stale value from an earlier chunk.
552+
"""
553+
config = RailsConfig.from_content(
554+
config={
555+
"models": [],
556+
"rails": {
557+
"output": {
558+
"flows": ["capture output"],
559+
"streaming": {"enabled": True, "chunk_size": 4, "context_size": 2},
560+
}
561+
},
562+
"streaming": False,
563+
},
564+
colang_content="""
565+
define user express greeting
566+
"hi"
567+
568+
define flow
569+
user express greeting
570+
bot tell joke
571+
572+
define subflow capture output
573+
execute capture_output(text=$bot_message)
574+
""",
575+
)
576+
577+
seen = []
578+
579+
@action(name="capture_output", output_mapping=lambda result: not result)
580+
async def capture_output(**params):
581+
# the substituted `text` kwarg must match this chunk's bot_message
582+
seen.append((params.get("text"), params["context"]["bot_message"]))
583+
return True
584+
585+
chat = TestChat(
586+
config,
587+
llm_completions=[
588+
' express greeting\nbot express greeting\n "hi"',
589+
' "one two three four five six"',
590+
],
591+
streaming=True,
592+
)
593+
chat.app.register_action(capture_output, name="capture_output")
594+
595+
async for _ in chat.app.stream_async(messages=[{"role": "user", "content": "hi"}]):
596+
pass
597+
598+
assert len(seen) >= 2 # response spans multiple chunks
599+
assert all(text == bot_message for text, bot_message in seen)
600+
601+
await asyncio.gather(*asyncio.all_tasks() - {asyncio.current_task()})
602+
603+
604+
@pytest.mark.asyncio
605+
async def test_streaming_output_rails_substitutes_user_message_param():
606+
"""Output rails that take the user message as a substituted kwarg
607+
(text=$user_message) receive the resolved user message on every streamed
608+
chunk, not the literal "$user_message" placeholder.
609+
"""
610+
config = RailsConfig.from_content(
611+
config={
612+
"models": [],
613+
"rails": {
614+
"output": {
615+
"flows": ["capture output"],
616+
"streaming": {"enabled": True, "chunk_size": 4, "context_size": 2},
617+
}
618+
},
619+
"streaming": False,
620+
},
621+
colang_content="""
622+
define user express greeting
623+
"hi"
624+
625+
define flow
626+
user express greeting
627+
bot tell joke
628+
629+
define subflow capture output
630+
execute capture_output(text=$user_message)
631+
""",
632+
)
633+
634+
seen = []
635+
636+
@action(name="capture_output", output_mapping=lambda result: not result)
637+
async def capture_output(**params):
638+
# the substituted `text` kwarg must be the resolved user message
639+
seen.append((params.get("text"), params["context"]["user_message"]))
640+
return True
641+
642+
chat = TestChat(
643+
config,
644+
llm_completions=[
645+
' express greeting\nbot express greeting\n "hi"',
646+
' "one two three four five six"',
647+
],
648+
streaming=True,
649+
)
650+
chat.app.register_action(capture_output, name="capture_output")
651+
652+
async for _ in chat.app.stream_async(messages=[{"role": "user", "content": "hi"}]):
653+
pass
654+
655+
assert seen # the output rail ran on at least one chunk
656+
# $user_message was resolved, not passed through as the literal placeholder
657+
assert all(text != "$user_message" for text, _ in seen)
658+
assert all(text == user_message for text, user_message in seen)
659+
660+
await asyncio.gather(*asyncio.all_tasks() - {asyncio.current_task()})

0 commit comments

Comments
 (0)