diff --git a/lib/crewai/src/crewai/agents/constants.py b/lib/crewai/src/crewai/agents/constants.py index 326d53d02..14129d2e4 100644 --- a/lib/crewai/src/crewai/agents/constants.py +++ b/lib/crewai/src/crewai/agents/constants.py @@ -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, +) diff --git a/lib/crewai/src/crewai/agents/parser.py b/lib/crewai/src/crewai/agents/parser.py index c338e8360..ad8a7e775 100644 --- a/lib/crewai/src/crewai/agents/parser.py +++ b/lib/crewai/src/crewai/agents/parser.py @@ -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')}", diff --git a/lib/crewai/tests/agents/test_crew_agent_parser.py b/lib/crewai/tests/agents/test_crew_agent_parser.py index f3076a036..71fa3c57d 100644 --- a/lib/crewai/tests/agents/test_crew_agent_parser.py +++ b/lib/crewai/tests/agents/test_crew_agent_parser.py @@ -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