diff --git a/src/crewai/agent.py b/src/crewai/agent.py index ec7ff03d8..b7e31a600 100644 --- a/src/crewai/agent.py +++ b/src/crewai/agent.py @@ -477,7 +477,8 @@ class Agent(BaseAgent): # result_as_answer set to True for tool_result in self.tools_results: # type: ignore # Item "None" of "list[Any] | None" has no attribute "__iter__" (not iterable) if tool_result.get("result_as_answer", False): - result = tool_result["result"] + from crewai.tools.tool_types import ToolAnswerResult + result = ToolAnswerResult(tool_result["result"]) crewai_event_bus.emit( self, event=AgentExecutionCompletedEvent(agent=self, task=task, output=result), diff --git a/src/crewai/task.py b/src/crewai/task.py index aa38cfff9..9fec274c3 100644 --- a/src/crewai/task.py +++ b/src/crewai/task.py @@ -38,6 +38,7 @@ from crewai.security import Fingerprint, SecurityConfig from crewai.tasks.output_format import OutputFormat from crewai.tasks.task_output import TaskOutput from crewai.tools.base_tool import BaseTool +from crewai.tools.tool_types import ToolAnswerResult from crewai.utilities.config import process_config from crewai.utilities.constants import NOT_SPECIFIED, _NotSpecified from crewai.utilities.guardrail import process_guardrail, GuardrailResult @@ -424,11 +425,14 @@ class Task(BaseModel): ) pydantic_output, json_output = self._export_output(result) + + raw_result = result.result if hasattr(result, 'result') else result + task_output = TaskOutput( name=self.name, description=self.description, expected_output=self.expected_output, - raw=result, + raw=raw_result, pydantic=pydantic_output, json_dict=json_output, agent=agent.role, @@ -695,8 +699,11 @@ Follow these guidelines: return copied_task def _export_output( - self, result: str + self, result: Union[str, ToolAnswerResult] ) -> Tuple[Optional[BaseModel], Optional[Dict[str, Any]]]: + if isinstance(result, ToolAnswerResult): + return None, None + pydantic_output: Optional[BaseModel] = None json_output: Optional[Dict[str, Any]] = None diff --git a/src/crewai/tools/tool_types.py b/src/crewai/tools/tool_types.py index 3e37fed2f..a6a656d0b 100644 --- a/src/crewai/tools/tool_types.py +++ b/src/crewai/tools/tool_types.py @@ -7,3 +7,13 @@ class ToolResult: result: str result_as_answer: bool = False + + +class ToolAnswerResult: + """Wrapper for tool results that should be used as final answers without conversion.""" + + def __init__(self, result: str): + self.result = result + + def __str__(self) -> str: + return self.result diff --git a/tests/test_result_as_answer_fix.py b/tests/test_result_as_answer_fix.py new file mode 100644 index 000000000..7374a8459 --- /dev/null +++ b/tests/test_result_as_answer_fix.py @@ -0,0 +1,240 @@ +import pytest +from unittest.mock import Mock, patch +from crewai.agent import Agent +from crewai.task import Task +from crewai.tools.base_tool import BaseTool +from crewai.tools.tool_types import ToolAnswerResult +from pydantic import BaseModel + + +class TestOutputModel(BaseModel): + message: str + status: str + + +class MockTool(BaseTool): + name: str = "mock_tool" + description: str = "A mock tool for testing" + result_as_answer: bool = False + + def _run(self, *args, **kwargs) -> str: + return "Mock tool output" + + +class MockLLM: + def call(self, messages, **kwargs): + return "LLM processed output" + + def __call__(self, messages, **kwargs): + return self.call(messages, **kwargs) + + +def test_tool_with_result_as_answer_true_bypasses_conversion(): + """Test that tools with result_as_answer=True return output without conversion.""" + tool = MockTool() + tool.result_as_answer = True + + agent = Agent( + role="test_agent", + goal="test goal", + backstory="test backstory", + llm=MockLLM(), + tools=[tool] + ) + + task = Task( + description="Test task", + expected_output="Test output", + agent=agent, + output_pydantic=TestOutputModel + ) + + agent.tools_results = [ + { + "tool": "mock_tool", + "result": "Plain string output that should not be converted", + "result_as_answer": True + } + ] + + with patch.object(agent, 'execute_task') as mock_execute: + mock_execute.return_value = ToolAnswerResult("Plain string output that should not be converted") + + result = task.execute_sync() + + assert result.raw == "Plain string output that should not be converted" + assert result.pydantic is None + assert result.json_dict is None + + +def test_tool_with_result_as_answer_false_applies_conversion(): + """Test that tools with result_as_answer=False still apply conversion when output_pydantic is set.""" + tool = MockTool() + tool.result_as_answer = False + + agent = Agent( + role="test_agent", + goal="test goal", + backstory="test backstory", + llm=MockLLM(), + tools=[tool] + ) + + task = Task( + description="Test task", + expected_output="Test output", + agent=agent, + output_pydantic=TestOutputModel + ) + + with patch.object(agent, 'execute_task') as mock_execute: + mock_execute.return_value = '{"message": "test", "status": "success"}' + + with patch('crewai.task.convert_to_model') as mock_convert: + mock_convert.return_value = TestOutputModel(message="test", status="success") + + result = task.execute_sync() + + assert mock_convert.called + assert result.pydantic is not None + assert isinstance(result.pydantic, TestOutputModel) + + +def test_multiple_tools_last_result_as_answer_wins(): + """Test that when multiple tools are used, the last one with result_as_answer=True is used.""" + agent = Agent( + role="test_agent", + goal="test goal", + backstory="test backstory", + llm=MockLLM() + ) + + task = Task( + description="Test task", + expected_output="Test output", + agent=agent + ) + + agent.tools_results = [ + { + "tool": "tool1", + "result": "First tool output", + "result_as_answer": False + }, + { + "tool": "tool2", + "result": "Second tool output", + "result_as_answer": True + }, + { + "tool": "tool3", + "result": "Third tool output", + "result_as_answer": False + }, + { + "tool": "tool4", + "result": "Final tool output that should be used", + "result_as_answer": True + } + ] + + with patch.object(agent, 'execute_task') as mock_execute: + mock_execute.return_value = ToolAnswerResult("Final tool output that should be used") + + result = task.execute_sync() + + assert result.raw == "Final tool output that should be used" + + +def test_tool_answer_result_wrapper(): + """Test the ToolAnswerResult wrapper class.""" + result = ToolAnswerResult("test output") + + assert str(result) == "test output" + + assert result.result == "test output" + + +def test_reproduction_of_issue_3335(): + """Reproduction test for GitHub issue #3335.""" + + tool = MockTool() + tool.result_as_answer = True + + def mock_tool_run(*args, **kwargs): + return "This is a plain string that should not be converted to JSON" + + tool._run = mock_tool_run + + agent = Agent( + role="test_agent", + goal="test goal", + backstory="test backstory", + llm=MockLLM(), + tools=[tool] + ) + + task = Task( + description="Test task", + expected_output="Test output", + agent=agent, + output_pydantic=TestOutputModel + ) + + agent.tools_results = [ + { + "tool": "mock_tool", + "result": "This is a plain string that should not be converted to JSON", + "result_as_answer": True + } + ] + + with patch.object(agent, 'execute_task') as mock_execute: + mock_execute.return_value = ToolAnswerResult("This is a plain string that should not be converted to JSON") + + result = task.execute_sync() + + assert result.raw == "This is a plain string that should not be converted to JSON" + assert result.pydantic is None + assert result.json_dict is None + + +def test_edge_case_complex_tool_output(): + """Test edge case with complex tool output that should be preserved.""" + complex_output = """ + This is a multi-line output + with special characters: !@#$%^&*() + and some JSON-like content: {"key": "value"} + but it should be preserved as-is when result_as_answer=True + """ + + agent = Agent( + role="test_agent", + goal="test goal", + backstory="test backstory", + llm=MockLLM() + ) + + task = Task( + description="Test task", + expected_output="Test output", + agent=agent, + output_pydantic=TestOutputModel + ) + + agent.tools_results = [ + { + "tool": "complex_tool", + "result": complex_output, + "result_as_answer": True + } + ] + + with patch.object(agent, 'execute_task') as mock_execute: + mock_execute.return_value = ToolAnswerResult(complex_output) + + result = task.execute_sync() + + assert result.raw == complex_output + assert result.pydantic is None + assert result.json_dict is None