fix: handle 'Action: None' in parser to prevent OutputParserError

When LLMs output 'Action: None' (or variations like 'Action: N/A'),
the parser now correctly treats this as a signal for a direct response
instead of raising an OutputParserError.

This fixes issue #4186 where the parser would fail and leak internal
'Thought:' text to users instead of providing a clean response.

Changes:
- Add ACTION_NONE_REGEX constant to match non-action values
- Update parse() to detect and handle Action: None patterns
- Convert Action: None to AgentFinish with the thought as output
- Add comprehensive tests for all variations

Closes #4186

Co-Authored-By: João <joao@crewai.com>
This commit is contained in:
Devin AI
2026-01-06 19:44:09 +00:00
parent b787d7e591
commit 37b75aeb6a
3 changed files with 124 additions and 0 deletions

View File

@@ -26,3 +26,9 @@ ACTION_REGEX: Final[re.Pattern[str]] = re.compile(
ACTION_INPUT_ONLY_REGEX: Final[re.Pattern[str]] = re.compile(
r"\s*Action\s*\d*\s*Input\s*\d*\s*:\s*(.*)", re.DOTALL
)
# Regex to match "Action: None" or similar non-action values (None, N/A, etc.)
# This captures the action value and any text that follows it
ACTION_NONE_REGEX: Final[re.Pattern[str]] = re.compile(
r"Action\s*\d*\s*:\s*(none|n/a|na|no action|no_action)(?:\s*[-:(]?\s*(.*))?",
re.IGNORECASE | re.DOTALL,
)

View File

@@ -12,6 +12,7 @@ from json_repair import repair_json # type: ignore[import-untyped]
from crewai.agents.constants import (
ACTION_INPUT_ONLY_REGEX,
ACTION_INPUT_REGEX,
ACTION_NONE_REGEX,
ACTION_REGEX,
FINAL_ANSWER_ACTION,
MISSING_ACTION_AFTER_THOUGHT_ERROR_MESSAGE,
@@ -118,6 +119,34 @@ def parse(text: str) -> AgentAction | AgentFinish:
thought=thought, tool=clean_action, tool_input=safe_tool_input, text=text
)
# Check for "Action: None" or similar non-action values
# This handles cases where the LLM indicates it cannot/should not use a tool
action_none_match = ACTION_NONE_REGEX.search(text)
if action_none_match:
# Extract any additional content after "Action: None"
additional_content = action_none_match.group(2)
if additional_content:
additional_content = additional_content.strip()
# Remove trailing parenthesis if present (from patterns like "Action: None (reason)")
if additional_content.startswith("(") and ")" in additional_content:
additional_content = additional_content.split(")", 1)[-1].strip()
elif additional_content.startswith(")"):
additional_content = additional_content[1:].strip()
# Build the final answer from thought and any additional content
final_answer = thought
if additional_content:
if final_answer:
final_answer = f"{final_answer}\n\n{additional_content}"
else:
final_answer = additional_content
# If we still have no content, use a generic message
if not final_answer:
final_answer = "I cannot perform this action with the available tools."
return AgentFinish(thought=thought, output=final_answer, text=text)
if not ACTION_REGEX.search(text):
raise OutputParserError(
f"{MISSING_ACTION_AFTER_THOUGHT_ERROR_MESSAGE}\n{_I18N.slice('final_answer_format')}",

View File

@@ -360,3 +360,92 @@ def test_integration_valid_and_invalid():
# TODO: ADD TEST TO MAKE SURE ** REMOVAL DOESN'T MESS UP ANYTHING
# Tests for Action: None handling (Issue #4186)
def test_action_none_basic():
"""Test that 'Action: None' is parsed as AgentFinish."""
text = "Thought: I cannot use any tool for this.\nAction: None"
result = parser.parse(text)
assert isinstance(result, AgentFinish)
assert "I cannot use any tool for this." in result.output
def test_action_none_with_reason_in_parentheses():
"""Test 'Action: None (reason)' format."""
text = "Thought: The tool is not available.\nAction: None (direct response required)"
result = parser.parse(text)
assert isinstance(result, AgentFinish)
assert "The tool is not available." in result.output
def test_action_none_lowercase():
"""Test that 'Action: none' (lowercase) is handled."""
text = "Thought: I should respond directly.\nAction: none"
result = parser.parse(text)
assert isinstance(result, AgentFinish)
assert "I should respond directly." in result.output
def test_action_na():
"""Test that 'Action: N/A' is handled."""
text = "Thought: No action needed here.\nAction: N/A"
result = parser.parse(text)
assert isinstance(result, AgentFinish)
assert "No action needed here." in result.output
def test_action_na_lowercase():
"""Test that 'Action: n/a' (lowercase) is handled."""
text = "Thought: This requires a direct answer.\nAction: n/a"
result = parser.parse(text)
assert isinstance(result, AgentFinish)
assert "This requires a direct answer." in result.output
def test_action_none_with_dash_separator():
"""Test 'Action: None - reason' format."""
text = "Thought: I need to provide a direct response.\nAction: None - direct response"
result = parser.parse(text)
assert isinstance(result, AgentFinish)
assert "I need to provide a direct response." in result.output
def test_action_none_with_additional_content():
"""Test 'Action: None' with additional content after."""
text = "Thought: I analyzed the request.\nAction: None\nHere is my direct response to your question."
result = parser.parse(text)
assert isinstance(result, AgentFinish)
assert "I analyzed the request." in result.output
def test_action_no_action():
"""Test that 'Action: no action' is handled."""
text = "Thought: I will respond without using tools.\nAction: no action"
result = parser.parse(text)
assert isinstance(result, AgentFinish)
assert "I will respond without using tools." in result.output
def test_action_none_without_thought():
"""Test 'Action: None' without a thought prefix."""
text = "Action: None"
result = parser.parse(text)
assert isinstance(result, AgentFinish)
assert result.output == "I cannot perform this action with the available tools."
def test_action_none_preserves_original_text():
"""Test that the original text is preserved in the result."""
text = "Thought: I cannot delegate this task.\nAction: None"
result = parser.parse(text)
assert isinstance(result, AgentFinish)
assert result.text == text
def test_action_none_with_colon_separator():
"""Test 'Action: None: reason' format."""
text = "Thought: Direct response needed.\nAction: None: providing direct answer"
result = parser.parse(text)
assert isinstance(result, AgentFinish)
assert "Direct response needed." in result.output