mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-01-07 21:54:10 -05:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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')}",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user