Skip to content

Commit 949a4f5

Browse files
committed
fix(library): unblock reasoning models in self-check and content-safety actions
Reasoning models (OpenAI o-series, gpt-5, DeepSeek-R1, Gemini 2.5, Qwen QwQ, etc.) spend output tokens on internal reasoning before producing visible text. The library's safety/self-check actions defaulted max_tokens to 3 (or 10 for topic safety), which reasoning models consume entirely on thinking, returning empty content with finish_reason='length'. Callers saw a silent "" result and the checks produced nonsense. - Bump the fallback _MAX_TOKENS from 3 to 1024 in: self_check/input_check, self_check/output_check, self_check/facts, content_safety (both input and output checks). Classical models still stop early via stop tokens / natural completion, so cost impact is negligible. User-configured max_tokens (via prompts config) still wins. - Add warn_if_truncated() in actions/llm/utils.py: emits a WARNING when response.content is empty and finish_reason == 'length'. Called from each patched action so any user who tunes max_tokens too low for a reasoning model gets an actionable log line instead of silent failure. - Unit-test warn_if_truncated for empty-with-length, non-empty, and non-length finish-reason cases. topic_safety is not affected (its max_tokens is computed but never passed to llm_call in the current code). Not touched here.
1 parent dbccf08 commit 949a4f5

6 files changed

Lines changed: 101 additions & 59 deletions

File tree

nemoguardrails/actions/llm/utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,25 @@ def _extract_content(response: LLMResponse) -> str:
396396
return response.content
397397

398398

399+
def warn_if_truncated(response: LLMResponse, task: str) -> None:
400+
"""Emit a warning if the LLM produced no visible content because it hit the max_tokens budget.
401+
402+
Reasoning models (OpenAI o-series, gpt-5, DeepSeek-R1, Gemini 2.5, Qwen QwQ, etc.)
403+
spend output tokens on internal reasoning before emitting visible text. A small
404+
max_tokens budget can be fully consumed by the reasoning phase, leaving empty
405+
content and finish_reason="length". The call succeeds silently and callers that
406+
only inspect response.content see nothing.
407+
"""
408+
if not response.content and response.finish_reason == "length":
409+
logger.warning(
410+
"Task %s: LLM returned empty content with finish_reason='length'. "
411+
"The max_tokens budget was likely consumed before any visible output. "
412+
"If using a reasoning model (o1/o3/o4-mini, gpt-5, deepseek-r1, "
413+
"gemini-2.5, etc.), increase the prompt's max_tokens in your config.",
414+
task,
415+
)
416+
417+
399418
def get_colang_history(
400419
events: List[dict],
401420
include_texts: bool = True,

nemoguardrails/library/content_safety/actions.py

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from typing import Dict, FrozenSet, Optional
1818

1919
from nemoguardrails.actions.actions import action
20-
from nemoguardrails.actions.llm.utils import llm_call
20+
from nemoguardrails.actions.llm.utils import llm_call, warn_if_truncated
2121
from nemoguardrails.context import llm_call_info_var
2222
from nemoguardrails.llm.cache import CacheInterface
2323
from nemoguardrails.llm.cache.utils import (
@@ -47,7 +47,7 @@ async def content_safety_check_input(
4747
model_caches: Optional[Dict[str, CacheInterface]] = None,
4848
**kwargs,
4949
) -> dict:
50-
_MAX_TOKENS = 3
50+
_MAX_TOKENS = 1024
5151
user_input: str = ""
5252

5353
if context is not None:
@@ -97,16 +97,14 @@ async def content_safety_check_input(
9797
log.debug(f"Content safety cache hit for model '{model_name}'")
9898
return cached_result
9999

100-
result = (
101-
await llm_call(
102-
llm,
103-
check_input_prompt,
104-
stop=stop,
105-
llm_params={"temperature": 1e-20, "max_tokens": max_tokens},
106-
)
107-
).content
108-
109-
result = llm_task_manager.parse_task_output(task, output=result)
100+
llm_response = await llm_call(
101+
llm,
102+
check_input_prompt,
103+
stop=stop,
104+
llm_params={"temperature": 1e-20, "max_tokens": max_tokens},
105+
)
106+
warn_if_truncated(llm_response, task)
107+
result = llm_task_manager.parse_task_output(task, output=llm_response.content)
110108

111109
is_safe, *violated_policies = result
112110

@@ -150,7 +148,7 @@ async def content_safety_check_output(
150148
model_caches: Optional[Dict[str, CacheInterface]] = None,
151149
**kwargs,
152150
) -> dict:
153-
_MAX_TOKENS = 3
151+
_MAX_TOKENS = 1024
154152
user_input: str = ""
155153
bot_response: str = ""
156154

@@ -203,16 +201,14 @@ async def content_safety_check_output(
203201
log.debug(f"Content safety output cache hit for model '{model_name}'")
204202
return cached_result
205203

206-
result = (
207-
await llm_call(
208-
llm,
209-
check_output_prompt,
210-
stop=stop,
211-
llm_params={"temperature": 1e-20, "max_tokens": max_tokens},
212-
)
213-
).content
214-
215-
result = llm_task_manager.parse_task_output(task, output=result)
204+
llm_response = await llm_call(
205+
llm,
206+
check_output_prompt,
207+
stop=stop,
208+
llm_params={"temperature": 1e-20, "max_tokens": max_tokens},
209+
)
210+
warn_if_truncated(llm_response, task)
211+
result = llm_task_manager.parse_task_output(task, output=llm_response.content)
216212

217213
is_safe, *violated_policies = result
218214

nemoguardrails/library/self_check/facts/actions.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from nemoguardrails import RailsConfig
2020
from nemoguardrails.actions import action
21-
from nemoguardrails.actions.llm.utils import llm_call
21+
from nemoguardrails.actions.llm.utils import llm_call, warn_if_truncated
2222
from nemoguardrails.context import llm_call_info_var
2323
from nemoguardrails.llm.taskmanager import LLMTaskManager
2424
from nemoguardrails.llm.types import Task
@@ -48,7 +48,7 @@ async def self_check_facts(
4848
**kwargs,
4949
):
5050
"""Checks the facts for the bot response by appropriately prompting the base llm."""
51-
_MAX_TOKENS = 3
51+
_MAX_TOKENS = 1024
5252
evidence = context.get("relevant_chunks", [])
5353
response = context.get("bot_message")
5454

@@ -70,14 +70,14 @@ async def self_check_facts(
7070
# Initialize the LLMCallInfo object
7171
llm_call_info_var.set(LLMCallInfo(task=task.value))
7272

73-
response = (
74-
await llm_call(
75-
llm,
76-
prompt,
77-
stop=stop,
78-
llm_params={"temperature": config.lowest_temperature, "max_tokens": max_tokens},
79-
)
80-
).content
73+
llm_response = await llm_call(
74+
llm,
75+
prompt,
76+
stop=stop,
77+
llm_params={"temperature": config.lowest_temperature, "max_tokens": max_tokens},
78+
)
79+
warn_if_truncated(llm_response, task.value)
80+
response = llm_response.content
8181

8282
if llm_task_manager.has_output_parser(task):
8383
result = llm_task_manager.parse_task_output(task, output=response)

nemoguardrails/library/self_check/input_check/actions.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from nemoguardrails import RailsConfig
2020
from nemoguardrails.actions.actions import ActionResult, action
21-
from nemoguardrails.actions.llm.utils import llm_call
21+
from nemoguardrails.actions.llm.utils import llm_call, warn_if_truncated
2222
from nemoguardrails.context import llm_call_info_var
2323
from nemoguardrails.llm.taskmanager import LLMTaskManager
2424
from nemoguardrails.llm.types import Task
@@ -46,7 +46,7 @@ async def self_check_input(
4646
True if the input should be allowed, False otherwise.
4747
"""
4848

49-
_MAX_TOKENS = 3
49+
_MAX_TOKENS = 1024
5050
user_input = context.get("user_message")
5151
task = Task.SELF_CHECK_INPUT
5252

@@ -64,17 +64,17 @@ async def self_check_input(
6464
# Initialize the LLMCallInfo object
6565
llm_call_info_var.set(LLMCallInfo(task=task.value))
6666

67-
response = (
68-
await llm_call(
69-
llm,
70-
prompt,
71-
stop=stop,
72-
llm_params={
73-
"temperature": config.lowest_temperature,
74-
"max_tokens": max_tokens,
75-
},
76-
)
77-
).content
67+
llm_response = await llm_call(
68+
llm,
69+
prompt,
70+
stop=stop,
71+
llm_params={
72+
"temperature": config.lowest_temperature,
73+
"max_tokens": max_tokens,
74+
},
75+
)
76+
warn_if_truncated(llm_response, task.value)
77+
response = llm_response.content
7878

7979
log.info(f"Input self-checking result is: `{response}`.")
8080

nemoguardrails/library/self_check/output_check/actions.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from nemoguardrails import RailsConfig
2020
from nemoguardrails.actions import action
21-
from nemoguardrails.actions.llm.utils import llm_call
21+
from nemoguardrails.actions.llm.utils import llm_call, warn_if_truncated
2222
from nemoguardrails.context import llm_call_info_var
2323
from nemoguardrails.llm.taskmanager import LLMTaskManager
2424
from nemoguardrails.llm.types import Task
@@ -48,7 +48,7 @@ async def self_check_output(
4848
True if the output should be allowed, False otherwise.
4949
"""
5050

51-
_MAX_TOKENS = 3
51+
_MAX_TOKENS = 1024
5252
bot_response = context.get("bot_message")
5353
user_input = context.get("user_message")
5454
bot_thinking = context.get("bot_thinking")
@@ -71,17 +71,17 @@ async def self_check_output(
7171
# Initialize the LLMCallInfo object
7272
llm_call_info_var.set(LLMCallInfo(task=task.value))
7373

74-
response = (
75-
await llm_call(
76-
llm,
77-
prompt,
78-
stop=stop,
79-
llm_params={
80-
"temperature": config.lowest_temperature,
81-
"max_tokens": max_tokens,
82-
},
83-
)
84-
).content
74+
llm_response = await llm_call(
75+
llm,
76+
prompt,
77+
stop=stop,
78+
llm_params={
79+
"temperature": config.lowest_temperature,
80+
"max_tokens": max_tokens,
81+
},
82+
)
83+
warn_if_truncated(llm_response, task.value)
84+
response = llm_response.content
8585

8686
log.info(f"Output self-checking result is: `{response}`.")
8787

tests/integrations/langchain/test_actions_llm_utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
_stream_llm_call,
2525
_update_token_stats_from_chunk,
2626
llm_call,
27+
warn_if_truncated,
2728
)
2829
from nemoguardrails.context import (
2930
llm_call_info_var,
@@ -640,3 +641,29 @@ async def test_clears_metadata_var_when_none(self):
640641
await _stream_llm_call(model, "test", StreamingHandler(), stop=None)
641642

642643
assert llm_response_metadata_var.get() is None
644+
645+
646+
class TestWarnIfTruncated:
647+
def test_warns_on_empty_content_with_length_finish(self, caplog):
648+
from nemoguardrails.types import LLMResponse
649+
650+
response = LLMResponse(content="", finish_reason="length")
651+
with caplog.at_level("WARNING"):
652+
warn_if_truncated(response, "self_check_input")
653+
assert any("self_check_input" in rec.message and "length" in rec.message for rec in caplog.records)
654+
655+
def test_silent_on_non_empty_content(self, caplog):
656+
from nemoguardrails.types import LLMResponse
657+
658+
response = LLMResponse(content="yes", finish_reason="length")
659+
with caplog.at_level("WARNING"):
660+
warn_if_truncated(response, "self_check_input")
661+
assert not caplog.records
662+
663+
def test_silent_on_non_length_finish_reason(self, caplog):
664+
from nemoguardrails.types import LLMResponse
665+
666+
response = LLMResponse(content="", finish_reason="stop")
667+
with caplog.at_level("WARNING"):
668+
warn_if_truncated(response, "self_check_input")
669+
assert not caplog.records

0 commit comments

Comments
 (0)