mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-09 17:05:08 +00:00
Compare commits
1 Commits
heitor/alw
...
devin/1765
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc7bc2ae94 |
@@ -24,10 +24,4 @@ repos:
|
||||
rev: 0.9.3
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
- repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.10.1
|
||||
hooks:
|
||||
- id: commitizen
|
||||
- id: commitizen-branch
|
||||
stages: [ pre-push ]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Trace collection listener for orchestrating trace collection."""
|
||||
|
||||
import os
|
||||
from typing import Any, ClassVar, cast
|
||||
from typing import Any, ClassVar
|
||||
import uuid
|
||||
|
||||
from typing_extensions import Self
|
||||
@@ -9,7 +9,6 @@ from typing_extensions import Self
|
||||
from crewai.cli.authentication.token import AuthError, get_auth_token
|
||||
from crewai.cli.version import get_crewai_version
|
||||
from crewai.events.base_event_listener import BaseEventListener
|
||||
from crewai.events.base_events import BaseEvent
|
||||
from crewai.events.event_bus import CrewAIEventsBus
|
||||
from crewai.events.listeners.tracing.first_time_trace_handler import (
|
||||
FirstTimeTraceHandler,
|
||||
@@ -84,7 +83,6 @@ from crewai.events.types.tool_usage_events import (
|
||||
ToolUsageStartedEvent,
|
||||
)
|
||||
from crewai.events.utils.console_formatter import ConsoleFormatter
|
||||
from crewai.task import Task
|
||||
|
||||
|
||||
class TraceCollectionListener(BaseEventListener):
|
||||
@@ -93,6 +91,8 @@ class TraceCollectionListener(BaseEventListener):
|
||||
complex_events: ClassVar[list[str]] = [
|
||||
"task_started",
|
||||
"task_completed",
|
||||
"llm_call_started",
|
||||
"llm_call_completed",
|
||||
"agent_execution_started",
|
||||
"agent_execution_completed",
|
||||
]
|
||||
@@ -567,89 +567,79 @@ class TraceCollectionListener(BaseEventListener):
|
||||
self.batch_manager.end_event_processing()
|
||||
|
||||
def _create_trace_event(
|
||||
self, event_type: str, source: Any, event: type[BaseEvent]
|
||||
self, event_type: str, source: Any, event: Any
|
||||
) -> TraceEvent:
|
||||
"""Create a trace event"""
|
||||
trace_event = TraceEvent(
|
||||
type=event_type,
|
||||
timestamp=event.timestamp.isoformat(),
|
||||
)
|
||||
if hasattr(event, "timestamp") and event.timestamp:
|
||||
trace_event = TraceEvent(
|
||||
type=event_type,
|
||||
timestamp=event.timestamp.isoformat(),
|
||||
)
|
||||
else:
|
||||
trace_event = TraceEvent(
|
||||
type=event_type,
|
||||
)
|
||||
|
||||
trace_event.event_data = self._build_event_data(event_type, event, source)
|
||||
|
||||
return trace_event
|
||||
|
||||
def _build_event_data(
|
||||
self, event_type: str, event: type[BaseEvent], source: Any
|
||||
self, event_type: str, event: Any, source: Any
|
||||
) -> dict[str, Any]:
|
||||
"""Build event data"""
|
||||
event_data = None
|
||||
|
||||
if event_type not in self.complex_events:
|
||||
event_data = safe_serialize_to_dict(event)
|
||||
elif event_type == "task_started":
|
||||
task_started_event: TaskStartedEvent = cast(TaskStartedEvent, event)
|
||||
event_data = {
|
||||
"task_description": cast(Task, task_started_event.task).description,
|
||||
"expected_output": cast(Task, task_started_event.task).expected_output,
|
||||
"task_name": cast(Task, task_started_event.task).name
|
||||
or cast(Task, task_started_event.task).description,
|
||||
"context": task_started_event.context,
|
||||
return safe_serialize_to_dict(event)
|
||||
if event_type == "task_started":
|
||||
return {
|
||||
"task_description": event.task.description,
|
||||
"expected_output": event.task.expected_output,
|
||||
"task_name": event.task.name or event.task.description,
|
||||
"context": event.context,
|
||||
"agent_role": source.agent.role,
|
||||
"task_id": str(cast(Task, task_started_event.task).id),
|
||||
"task_id": str(event.task.id),
|
||||
}
|
||||
elif event_type == "task_completed":
|
||||
task_completed_event: TaskCompletedEvent = cast(TaskCompletedEvent, event)
|
||||
event_data = {
|
||||
"task_description": cast(Task, task_completed_event.task).description
|
||||
if cast(Task, task_completed_event.task)
|
||||
if event_type == "task_completed":
|
||||
return {
|
||||
"task_description": event.task.description if event.task else None,
|
||||
"task_name": event.task.name or event.task.description
|
||||
if event.task
|
||||
else None,
|
||||
"task_name": cast(Task, task_completed_event.task).name
|
||||
or cast(Task, task_completed_event.task).description
|
||||
if task_completed_event.task
|
||||
else None,
|
||||
"task_id": str(cast(Task, task_completed_event.task).id)
|
||||
if cast(Task, task_completed_event.task)
|
||||
else None,
|
||||
"output_raw": task_completed_event.output.raw
|
||||
if task_completed_event.output
|
||||
else None,
|
||||
"output_format": str(task_completed_event.output.output_format)
|
||||
if task_completed_event.output
|
||||
else None,
|
||||
"agent_role": task_completed_event.output.agent
|
||||
if task_completed_event.output
|
||||
"task_id": str(event.task.id) if event.task else None,
|
||||
"output_raw": event.output.raw if event.output else None,
|
||||
"output_format": str(event.output.output_format)
|
||||
if event.output
|
||||
else None,
|
||||
"agent_role": event.output.agent if event.output else None,
|
||||
}
|
||||
elif event_type == "agent_execution_started":
|
||||
agent_execution_started_event: AgentExecutionStartedEvent = cast(
|
||||
AgentExecutionStartedEvent, event
|
||||
if event_type == "agent_execution_started":
|
||||
return {
|
||||
"agent_role": event.agent.role,
|
||||
"agent_goal": event.agent.goal,
|
||||
"agent_backstory": event.agent.backstory,
|
||||
}
|
||||
if event_type == "agent_execution_completed":
|
||||
return {
|
||||
"agent_role": event.agent.role,
|
||||
"agent_goal": event.agent.goal,
|
||||
"agent_backstory": event.agent.backstory,
|
||||
}
|
||||
if event_type == "llm_call_started":
|
||||
event_data = safe_serialize_to_dict(event)
|
||||
event_data["task_name"] = (
|
||||
event.task_name or event.task_description
|
||||
if hasattr(event, "task_name") and event.task_name
|
||||
else None
|
||||
)
|
||||
event_data = {
|
||||
"agent_role": agent_execution_started_event.agent.role,
|
||||
"agent_goal": agent_execution_started_event.agent.goal,
|
||||
"agent_backstory": agent_execution_started_event.agent.backstory,
|
||||
}
|
||||
elif event_type == "agent_execution_completed":
|
||||
agent_execution_completed_event: AgentExecutionCompletedEvent = cast(
|
||||
AgentExecutionCompletedEvent, event
|
||||
)
|
||||
event_data = {
|
||||
"agent_role": agent_execution_completed_event.agent.role,
|
||||
"agent_goal": agent_execution_completed_event.agent.goal,
|
||||
"agent_backstory": agent_execution_completed_event.agent.backstory,
|
||||
}
|
||||
else:
|
||||
event_data = {
|
||||
"event_type": event_type,
|
||||
"event": safe_serialize_to_dict(event),
|
||||
"source": source,
|
||||
}
|
||||
return event_data
|
||||
if event_type == "llm_call_completed":
|
||||
return safe_serialize_to_dict(event)
|
||||
|
||||
if "timestamp" not in event_data.keys():
|
||||
event_data["timestamp"] = event.timestamp.isoformat()
|
||||
|
||||
return event_data
|
||||
return {
|
||||
"event_type": event_type,
|
||||
"event": safe_serialize_to_dict(event),
|
||||
"source": source,
|
||||
}
|
||||
|
||||
def _show_tracing_disabled_message(self) -> None:
|
||||
"""Show a message when tracing is disabled."""
|
||||
|
||||
@@ -9,7 +9,6 @@ class TaskStartedEvent(BaseEvent):
|
||||
|
||||
type: str = "task_started"
|
||||
context: str | None
|
||||
# TODO: Type this correctly with 'Task' type, currently impossible due to circular dependency
|
||||
task: Any | None = None
|
||||
|
||||
def __init__(self, **data):
|
||||
|
||||
@@ -169,7 +169,18 @@ def handle_max_iterations_exceeded(
|
||||
)
|
||||
raise ValueError("Invalid response from LLM call - None or empty.")
|
||||
|
||||
formatted = format_answer(answer=answer)
|
||||
try:
|
||||
formatted = format_answer(answer=answer)
|
||||
except OutputParserError:
|
||||
printer.print(
|
||||
content="Failed to parse forced final answer. Returning raw response.",
|
||||
color="yellow",
|
||||
)
|
||||
return AgentFinish(
|
||||
thought="Failed to parse LLM response during max iterations",
|
||||
output=answer,
|
||||
text=answer,
|
||||
)
|
||||
|
||||
# If format_answer returned an AgentAction, convert it to AgentFinish
|
||||
if isinstance(formatted, AgentFinish):
|
||||
@@ -206,9 +217,15 @@ def format_answer(answer: str) -> AgentAction | AgentFinish:
|
||||
|
||||
Returns:
|
||||
Either an AgentAction or AgentFinish
|
||||
|
||||
Raises:
|
||||
OutputParserError: If parsing fails due to malformed LLM output format.
|
||||
This allows the retry logic in _invoke_loop() to handle the error.
|
||||
"""
|
||||
try:
|
||||
return parse(answer)
|
||||
except OutputParserError:
|
||||
raise
|
||||
except Exception:
|
||||
return AgentFinish(
|
||||
thought="Failed to parse LLM response",
|
||||
|
||||
272
lib/crewai/tests/utilities/test_agent_utils.py
Normal file
272
lib/crewai/tests/utilities/test_agent_utils.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""Tests for agent_utils module.
|
||||
|
||||
These tests cover the format_answer() and handle_max_iterations_exceeded() functions,
|
||||
specifically testing the fix for issue #4113 where OutputParserError was being
|
||||
swallowed instead of being re-raised for retry logic.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.agents.parser import (
|
||||
AgentAction,
|
||||
AgentFinish,
|
||||
OutputParserError,
|
||||
)
|
||||
from crewai.utilities.agent_utils import (
|
||||
format_answer,
|
||||
handle_max_iterations_exceeded,
|
||||
process_llm_response,
|
||||
)
|
||||
|
||||
|
||||
class TestFormatAnswer:
|
||||
"""Tests for the format_answer function."""
|
||||
|
||||
def test_format_answer_with_valid_action(self) -> None:
|
||||
"""Test that format_answer correctly parses a valid action."""
|
||||
answer = "Thought: Let's search\nAction: search\nAction Input: query"
|
||||
result = format_answer(answer)
|
||||
assert isinstance(result, AgentAction)
|
||||
assert result.tool == "search"
|
||||
assert result.tool_input == "query"
|
||||
|
||||
def test_format_answer_with_valid_final_answer(self) -> None:
|
||||
"""Test that format_answer correctly parses a valid final answer."""
|
||||
answer = "Thought: I found the answer\nFinal Answer: The result is 42"
|
||||
result = format_answer(answer)
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.output == "The result is 42"
|
||||
|
||||
def test_format_answer_raises_output_parser_error_for_malformed_output(
|
||||
self,
|
||||
) -> None:
|
||||
"""Test that format_answer re-raises OutputParserError for malformed output.
|
||||
|
||||
This is the core fix for issue #4113. Previously, format_answer would catch
|
||||
all exceptions and return AgentFinish, which broke the retry logic.
|
||||
"""
|
||||
malformed_answer = """Thought
|
||||
The user wants to verify something.
|
||||
Action
|
||||
Video Analysis Tool
|
||||
Action Input:
|
||||
{"query": "Is there something?"}"""
|
||||
|
||||
with pytest.raises(OutputParserError):
|
||||
format_answer(malformed_answer)
|
||||
|
||||
def test_format_answer_raises_output_parser_error_missing_action(self) -> None:
|
||||
"""Test that format_answer re-raises OutputParserError when Action is missing."""
|
||||
answer = "Thought: Let's search\nAction Input: query"
|
||||
with pytest.raises(OutputParserError) as exc_info:
|
||||
format_answer(answer)
|
||||
assert "Action:" in str(exc_info.value)
|
||||
|
||||
def test_format_answer_raises_output_parser_error_missing_action_input(
|
||||
self,
|
||||
) -> None:
|
||||
"""Test that format_answer re-raises OutputParserError when Action Input is missing."""
|
||||
answer = "Thought: Let's search\nAction: search"
|
||||
with pytest.raises(OutputParserError) as exc_info:
|
||||
format_answer(answer)
|
||||
assert "Action Input:" in str(exc_info.value)
|
||||
|
||||
def test_format_answer_returns_agent_finish_for_generic_exception(self) -> None:
|
||||
"""Test that format_answer returns AgentFinish for non-OutputParserError exceptions."""
|
||||
with patch(
|
||||
"crewai.utilities.agent_utils.parse",
|
||||
side_effect=ValueError("Unexpected error"),
|
||||
):
|
||||
result = format_answer("some answer")
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.thought == "Failed to parse LLM response"
|
||||
assert result.output == "some answer"
|
||||
|
||||
|
||||
class TestProcessLlmResponse:
|
||||
"""Tests for the process_llm_response function."""
|
||||
|
||||
def test_process_llm_response_raises_output_parser_error(self) -> None:
|
||||
"""Test that process_llm_response propagates OutputParserError."""
|
||||
malformed_answer = "Thought\nMissing colons\nAction\nSome Tool"
|
||||
with pytest.raises(OutputParserError):
|
||||
process_llm_response(malformed_answer, use_stop_words=True)
|
||||
|
||||
def test_process_llm_response_with_valid_action(self) -> None:
|
||||
"""Test that process_llm_response correctly processes a valid action."""
|
||||
answer = "Thought: Let's search\nAction: search\nAction Input: query"
|
||||
result = process_llm_response(answer, use_stop_words=True)
|
||||
assert isinstance(result, AgentAction)
|
||||
assert result.tool == "search"
|
||||
|
||||
def test_process_llm_response_with_valid_final_answer(self) -> None:
|
||||
"""Test that process_llm_response correctly processes a valid final answer."""
|
||||
answer = "Thought: Done\nFinal Answer: The result"
|
||||
result = process_llm_response(answer, use_stop_words=True)
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.output == "The result"
|
||||
|
||||
|
||||
class TestHandleMaxIterationsExceeded:
|
||||
"""Tests for the handle_max_iterations_exceeded function."""
|
||||
|
||||
def test_handle_max_iterations_exceeded_with_valid_final_answer(self) -> None:
|
||||
"""Test that handle_max_iterations_exceeded returns AgentFinish for valid output."""
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.call.return_value = "Thought: Done\nFinal Answer: The final result"
|
||||
mock_printer = MagicMock()
|
||||
mock_i18n = MagicMock()
|
||||
mock_i18n.errors.return_value = "Please provide final answer"
|
||||
|
||||
result = handle_max_iterations_exceeded(
|
||||
formatted_answer=None,
|
||||
printer=mock_printer,
|
||||
i18n=mock_i18n,
|
||||
messages=[],
|
||||
llm=mock_llm,
|
||||
callbacks=[],
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.output == "The final result"
|
||||
|
||||
def test_handle_max_iterations_exceeded_with_valid_action_converts_to_finish(
|
||||
self,
|
||||
) -> None:
|
||||
"""Test that handle_max_iterations_exceeded converts AgentAction to AgentFinish."""
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.call.return_value = (
|
||||
"Thought: Using tool\nAction: search\nAction Input: query"
|
||||
)
|
||||
mock_printer = MagicMock()
|
||||
mock_i18n = MagicMock()
|
||||
mock_i18n.errors.return_value = "Please provide final answer"
|
||||
|
||||
result = handle_max_iterations_exceeded(
|
||||
formatted_answer=None,
|
||||
printer=mock_printer,
|
||||
i18n=mock_i18n,
|
||||
messages=[],
|
||||
llm=mock_llm,
|
||||
callbacks=[],
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentFinish)
|
||||
|
||||
def test_handle_max_iterations_exceeded_catches_output_parser_error(self) -> None:
|
||||
"""Test that handle_max_iterations_exceeded catches OutputParserError and returns AgentFinish.
|
||||
|
||||
This prevents infinite loops when the forced final answer is malformed.
|
||||
Without this safeguard, the OutputParserError would bubble up to _invoke_loop(),
|
||||
which would retry, hit max iterations again, and loop forever.
|
||||
"""
|
||||
malformed_response = """Thought
|
||||
Missing colons everywhere
|
||||
Action
|
||||
Some Tool
|
||||
Action Input:
|
||||
{"query": "test"}"""
|
||||
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.call.return_value = malformed_response
|
||||
mock_printer = MagicMock()
|
||||
mock_i18n = MagicMock()
|
||||
mock_i18n.errors.return_value = "Please provide final answer"
|
||||
|
||||
result = handle_max_iterations_exceeded(
|
||||
formatted_answer=None,
|
||||
printer=mock_printer,
|
||||
i18n=mock_i18n,
|
||||
messages=[],
|
||||
llm=mock_llm,
|
||||
callbacks=[],
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.output == malformed_response
|
||||
assert "Failed to parse LLM response during max iterations" in result.thought
|
||||
mock_printer.print.assert_any_call(
|
||||
content="Failed to parse forced final answer. Returning raw response.",
|
||||
color="yellow",
|
||||
)
|
||||
|
||||
def test_handle_max_iterations_exceeded_with_previous_formatted_answer(
|
||||
self,
|
||||
) -> None:
|
||||
"""Test that handle_max_iterations_exceeded uses previous answer text."""
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.call.return_value = "Thought: Done\nFinal Answer: New result"
|
||||
mock_printer = MagicMock()
|
||||
mock_i18n = MagicMock()
|
||||
mock_i18n.errors.return_value = "Please provide final answer"
|
||||
|
||||
previous_answer = AgentAction(
|
||||
thought="Previous thought",
|
||||
tool="search",
|
||||
tool_input="query",
|
||||
text="Previous text",
|
||||
)
|
||||
|
||||
result = handle_max_iterations_exceeded(
|
||||
formatted_answer=previous_answer,
|
||||
printer=mock_printer,
|
||||
i18n=mock_i18n,
|
||||
messages=[],
|
||||
llm=mock_llm,
|
||||
callbacks=[],
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentFinish)
|
||||
assert result.output == "New result"
|
||||
|
||||
def test_handle_max_iterations_exceeded_raises_on_empty_response(self) -> None:
|
||||
"""Test that handle_max_iterations_exceeded raises ValueError for empty response."""
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.call.return_value = ""
|
||||
mock_printer = MagicMock()
|
||||
mock_i18n = MagicMock()
|
||||
mock_i18n.errors.return_value = "Please provide final answer"
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid response from LLM call"):
|
||||
handle_max_iterations_exceeded(
|
||||
formatted_answer=None,
|
||||
printer=mock_printer,
|
||||
i18n=mock_i18n,
|
||||
messages=[],
|
||||
llm=mock_llm,
|
||||
callbacks=[],
|
||||
)
|
||||
|
||||
|
||||
class TestRetryLogicIntegration:
|
||||
"""Integration tests to verify the retry logic works correctly with the fix."""
|
||||
|
||||
def test_malformed_output_allows_retry_in_format_answer(self) -> None:
|
||||
"""Test that malformed output raises OutputParserError which can be caught for retry.
|
||||
|
||||
This simulates what happens in _invoke_loop() when the LLM returns malformed output.
|
||||
The OutputParserError should be raised so the loop can catch it and retry.
|
||||
"""
|
||||
malformed_outputs = [
|
||||
"Thought\nMissing colon after Thought",
|
||||
"Thought: OK\nAction\nMissing colon after Action",
|
||||
"Thought: OK\nAction: tool\nAction Input\nMissing colon",
|
||||
"Random text without any structure",
|
||||
]
|
||||
|
||||
for malformed in malformed_outputs:
|
||||
with pytest.raises(OutputParserError):
|
||||
format_answer(malformed)
|
||||
|
||||
def test_valid_output_does_not_raise(self) -> None:
|
||||
"""Test that valid outputs are parsed correctly without raising."""
|
||||
valid_outputs = [
|
||||
("Thought: Let's search\nAction: search\nAction Input: query", AgentAction),
|
||||
("Thought: Done\nFinal Answer: The result", AgentFinish),
|
||||
]
|
||||
|
||||
for output, expected_type in valid_outputs:
|
||||
result = format_answer(output)
|
||||
assert isinstance(result, expected_type)
|
||||
Reference in New Issue
Block a user