Skip to content

Commit 526d6dc

Browse files
committed
Fix inspired by Devin AI proposed fix
1 parent fadbc51 commit 526d6dc

File tree

5 files changed

+264
-35
lines changed

5 files changed

+264
-35
lines changed

src/crewai/agent.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,9 @@ def execute_task(
477477
# result_as_answer set to True
478478
for tool_result in self.tools_results: # type: ignore # Item "None" of "list[Any] | None" has no attribute "__iter__" (not iterable)
479479
if tool_result.get("result_as_answer", False):
480-
result = tool_result["result"]
480+
from crewai.tools.tool_types import ToolAnswerResult
481+
result = ToolAnswerResult(tool_result["result"]) # type: ignore
482+
# result = tool_result["result"]
481483
crewai_event_bus.emit(
482484
self,
483485
event=AgentExecutionCompletedEvent(agent=self, task=task, output=result),

src/crewai/task.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from crewai.tasks.output_format import OutputFormat
3939
from crewai.tasks.task_output import TaskOutput
4040
from crewai.tools.base_tool import BaseTool
41+
from crewai.tools.tool_types import ToolAnswerResult
4142
from crewai.utilities.config import process_config
4243
from crewai.utilities.constants import NOT_SPECIFIED, _NotSpecified
4344
from crewai.utilities.guardrail import process_guardrail, GuardrailResult
@@ -425,18 +426,22 @@ def _execute_core(
425426

426427
self.processed_by_agents.add(agent.role)
427428
crewai_event_bus.emit(self, TaskStartedEvent(context=context, task=self))
429+
428430
result = agent.execute_task(
429431
task=self,
430432
context=context,
431433
tools=tools,
432434
)
433435

434436
pydantic_output, json_output = self._export_output(result)
437+
438+
raw_result = result.result if hasattr(result, 'result') else result
439+
435440
task_output = TaskOutput(
436441
name=self.name,
437442
description=self.description,
438443
expected_output=self.expected_output,
439-
raw=result,
444+
raw=raw_result,
440445
pydantic=pydantic_output,
441446
json_dict=json_output,
442447
agent=agent.role,
@@ -714,8 +719,11 @@ def get_agent_by_role(role: str) -> Union["BaseAgent", None]:
714719
return copied_task
715720

716721
def _export_output(
717-
self, result: str
722+
self, result: Union[str, ToolAnswerResult]
718723
) -> Tuple[Optional[BaseModel], Optional[Dict[str, Any]]]:
724+
if isinstance(result, ToolAnswerResult):
725+
return None, None
726+
719727
pydantic_output: Optional[BaseModel] = None
720728
json_output: Optional[Dict[str, Any]] = None
721729

src/crewai/tools/tool_types.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,12 @@ class ToolResult:
77

88
result: str
99
result_as_answer: bool = False
10+
11+
class ToolAnswerResult:
12+
"""Wrapper for tool results that should be used as final answers without conversion."""
13+
14+
def __init__(self, result: str):
15+
self.result = result
16+
17+
def __str__(self) -> str:
18+
return self.result

src/crewai/tools/tool_usage.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ def _use(
311311
hasattr(available_tool, "result_as_answer")
312312
and available_tool.result_as_answer # type: ignore # Item "None" of "Any | None" has no attribute "cache_function"
313313
):
314+
raise Exception("Did I hit this?")
314315
result_as_answer = available_tool.result_as_answer # type: ignore # Item "None" of "Any | None" has no attribute "result_as_answer"
315316
data["result_as_answer"] = result_as_answer # type: ignore
316317

Lines changed: 241 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,241 @@
1-
from crewai import Agent, Task, Crew
2-
from crewai.tools import tool
3-
from unittest.mock import patch
4-
5-
@tool("Simple Echo")
6-
def echo_tool():
7-
"""Clear description for what this tool is useful for, your agent will need this information to use it."""
8-
return "TOOL_OUTPUT_SHOULD_NOT_BE_CHANGED"
9-
10-
def test_tool_result_as_answer_bypasses_formatting():
11-
with patch("crewai.llms.base_llm.BaseLLM.call") as mock_call:
12-
mock_call.return_value = "Final Answer: TOOL_OUTPUT_SHOULD_NOT_BE_CHANGED"
13-
14-
agent = Agent(
15-
role="tester",
16-
goal="test result_as_answer",
17-
backstory="You're just here to echo things.",
18-
tools=[echo_tool],
19-
verbose=False
20-
)
21-
22-
task = Task(
23-
description="Echo something",
24-
agent=agent,
25-
expected_output="TOOL_OUTPUT_SHOULD_NOT_BE_CHANGED",
26-
result_as_answer=True
27-
)
28-
29-
# crew = Crew(tasks=[task])
30-
result = echo_tool.run()
31-
32-
assert result == "TOOL_OUTPUT_SHOULD_NOT_BE_CHANGED"
1+
from unittest.mock import patch, MagicMock
2+
from crewai.agent import Agent
3+
from crewai.crew import Crew
4+
from crewai.task import Task
5+
from crewai.tools.base_tool import BaseTool
6+
from crewai.tools.tool_types import ToolAnswerResult
7+
from pydantic import BaseModel
8+
9+
10+
class outputModel(BaseModel):
11+
message: str
12+
status: str
13+
14+
15+
class MockTool(BaseTool):
16+
name: str = "mock_tool"
17+
description: str = "A mock tool for testing"
18+
result_as_answer: bool = False
19+
20+
def _run(self, *args, **kwargs) -> str:
21+
return "Mock tool output"
22+
23+
24+
class MockLLM:
25+
def call(self, messages, **kwargs):
26+
return "LLM processed output"
27+
28+
def __call__(self, messages, **kwargs):
29+
return self.call(messages, **kwargs)
30+
31+
def test_tool_with_result_as_answer_true_bypasses_conversion():
32+
"""Test that tools with result_as_answer=True return output without conversion."""
33+
tool = MockTool()
34+
tool.result_as_answer = True
35+
36+
agent = Agent(
37+
role="test_agent",
38+
goal="test goal",
39+
backstory="test backstory",
40+
llm=MockLLM(),
41+
tools=[tool]
42+
)
43+
44+
task = Task(
45+
description="Test task",
46+
expected_output="Test output",
47+
agent=agent,
48+
output_pydantic=outputModel
49+
)
50+
51+
agent.tools_results = [
52+
{
53+
"tool": "mock_tool",
54+
"result": "Plain string output that should not be converted",
55+
"result_as_answer": True
56+
}
57+
]
58+
59+
60+
with patch.object(Agent, "execute_task", return_value=ToolAnswerResult(
61+
"Plain string output that should not be converted"
62+
)):
63+
result = task.execute_sync()
64+
65+
assert result.raw == "Plain string output that should not be converted"
66+
assert result.pydantic is None
67+
assert result.json_dict is None
68+
69+
70+
def test_tool_with_result_as_answer_false_applies_conversion():
71+
"""Test that tools with result_as_answer=False still apply conversion when output_pydantic is set."""
72+
tool = MockTool()
73+
tool.result_as_answer = False
74+
75+
agent = Agent(
76+
role="test_agent",
77+
goal="test goal",
78+
backstory="test backstory",
79+
llm=MockLLM(),
80+
tools=[tool]
81+
)
82+
83+
task = Task(
84+
description="Test task",
85+
expected_output="Test output",
86+
agent=agent,
87+
output_pydantic=outputModel
88+
)
89+
90+
with patch.object(agent, 'execute_task') as mock_execute:
91+
mock_execute.return_value = '{"message": "test", "status": "success"}'
92+
93+
with patch('crewai.task.convert_to_model') as mock_convert:
94+
mock_convert.return_value = outputModel(message="test", status="success")
95+
96+
result = task.execute_sync()
97+
98+
assert mock_convert.called
99+
assert result.pydantic is not None
100+
assert isinstance(result.pydantic, outputModel)
101+
102+
103+
def test_multiple_tools_last_result_as_answer_wins():
104+
"""Test that when multiple tools are used, the last one with result_as_answer=True is used."""
105+
agent = Agent(
106+
role="test_agent",
107+
goal="test goal",
108+
backstory="test backstory",
109+
llm=MockLLM()
110+
)
111+
112+
task = Task(
113+
description="Test task",
114+
expected_output="Test output",
115+
agent=agent
116+
)
117+
118+
agent.tools_results = [
119+
{
120+
"tool": "tool1",
121+
"result": "First tool output",
122+
"result_as_answer": False
123+
},
124+
{
125+
"tool": "tool2",
126+
"result": "Second tool output",
127+
"result_as_answer": True
128+
},
129+
{
130+
"tool": "tool3",
131+
"result": "Third tool output",
132+
"result_as_answer": False
133+
},
134+
{
135+
"tool": "tool4",
136+
"result": "Final tool output that should be used",
137+
"result_as_answer": True
138+
}
139+
]
140+
141+
with patch.object(agent, 'execute_task') as mock_execute:
142+
mock_execute.return_value = ToolAnswerResult("Final tool output that should be used")
143+
144+
result = task.execute_sync()
145+
146+
assert result.raw == "Final tool output that should be used"
147+
148+
149+
def test_tool_answer_result_wrapper():
150+
"""Test the ToolAnswerResult wrapper class."""
151+
result = ToolAnswerResult("test output")
152+
153+
assert str(result) == "test output"
154+
155+
assert result.result == "test output"
156+
157+
158+
def test_reproduction_of_issue_3335():
159+
"""Reproduction test for GitHub issue #3335."""
160+
161+
tool = MockTool()
162+
tool.result_as_answer = True
163+
164+
def mock_tool_run(*args, **kwargs):
165+
return "This is a plain string that should not be converted to JSON"
166+
167+
tool._run = mock_tool_run
168+
169+
agent = Agent(
170+
role="test_agent",
171+
goal="test goal",
172+
backstory="test backstory",
173+
llm=MockLLM(),
174+
tools=[tool]
175+
)
176+
177+
task = Task(
178+
description="Test task",
179+
expected_output="Test output",
180+
agent=agent,
181+
output_pydantic=outputModel
182+
)
183+
184+
agent.tools_results = [
185+
{
186+
"tool": "mock_tool",
187+
"result": "This is a plain string that should not be converted to JSON",
188+
"result_as_answer": True
189+
}
190+
]
191+
192+
with patch.object(agent, 'execute_task') as mock_execute:
193+
mock_execute.return_value = ToolAnswerResult("This is a plain string that should not be converted to JSON")
194+
195+
result = task.execute_sync()
196+
197+
assert result.raw == "This is a plain string that should not be converted to JSON"
198+
assert result.pydantic is None
199+
assert result.json_dict is None
200+
201+
202+
def test_edge_case_complex_tool_output():
203+
"""Test edge case with complex tool output that should be preserved."""
204+
complex_output = """
205+
This is a multi-line output
206+
with special characters: !@#$%^&*()
207+
and some JSON-like content: {"key": "value"}
208+
but it should be preserved as-is when result_as_answer=True
209+
"""
210+
211+
agent = Agent(
212+
role="test_agent",
213+
goal="test goal",
214+
backstory="test backstory",
215+
llm=MockLLM()
216+
)
217+
218+
task = Task(
219+
description="Test task",
220+
expected_output="Test output",
221+
agent=agent,
222+
output_pydantic=outputModel
223+
)
224+
225+
agent.tools_results = [
226+
{
227+
"tool": "complex_tool",
228+
"result": complex_output,
229+
"result_as_answer": True
230+
}
231+
]
232+
233+
with patch.object(agent, 'execute_task') as mock_execute:
234+
mock_execute.return_value = ToolAnswerResult(complex_output)
235+
236+
result = task.execute_sync()
237+
238+
assert result.raw == complex_output
239+
assert result.pydantic is None
240+
assert result.json_dict is None
241+

0 commit comments

Comments
 (0)