Fix A2A delegation loop when remote agent returns 'completed' status

Fixes #3899

The issue was that when a remote A2A agent responded with status 'completed',
the server agent was ignoring it and delegating the same request again. This
caused an infinite loop until max_turns was reached.

The root cause was in _delegate_to_a2a() where both 'completed' and
'input_required' statuses were handled identically. The code would call
_handle_agent_response_and_continue() which could return (None, next_request),
causing the loop to continue even though the remote agent said it was completed.

The fix differentiates between the two statuses:
- 'completed': Extract the final message from the a2a_result or conversation
  history and return immediately without consulting the LLM again
- 'input_required': Continue with the existing behavior of consulting the LLM
  for next steps

Added comprehensive tests to verify:
1. Delegation stops immediately on 'completed' status
2. Delegation continues properly on 'input_required' status
3. Empty history with 'completed' status is handled gracefully
4. Final message is extracted from history when result is empty

Co-Authored-By: João <joao@crewai.com>
This commit is contained in:
Devin AI
2025-11-12 21:04:45 +00:00
parent fbe4aa4bd1
commit 5a3cd627bf
3 changed files with 4607 additions and 4032 deletions

View File

@@ -497,7 +497,37 @@ def _delegate_to_a2a(
conversation_history = a2a_result.get("history", [])
if a2a_result["status"] in ["completed", "input_required"]:
if a2a_result["status"] == "completed":
# Do NOT call _handle_agent_response_and_continue as it may trigger another delegation
final_message = a2a_result.get("result", "")
# If result is empty, try to extract from conversation history
if not final_message and conversation_history:
for msg in reversed(conversation_history):
if msg.role == Role.agent:
text_parts = [
part.root.text for part in msg.parts if part.root.kind == "text"
]
final_message = (
" ".join(text_parts) if text_parts else "Conversation completed"
)
break
if not final_message:
final_message = "Conversation completed"
crewai_event_bus.emit(
None,
A2AConversationCompletedEvent(
status="completed",
final_result=final_message,
error=None,
total_turns=turn_num + 1,
),
)
return final_message
if a2a_result["status"] == "input_required":
final_result, next_request = _handle_agent_response_and_continue(
self=self,
a2a_result=a2a_result,

View File

@@ -0,0 +1,356 @@
"""Test A2A delegation properly handles 'completed' status without looping."""
from unittest.mock import MagicMock, Mock, patch
from uuid import uuid4
import pytest
from crewai import Agent, Task
from crewai.a2a.config import A2AConfig
try:
from a2a.types import AgentCard, Message, Part, Role, TextPart
A2A_SDK_INSTALLED = True
except ImportError:
A2A_SDK_INSTALLED = False
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
def test_a2a_delegation_stops_on_completed_status():
"""Test that A2A delegation stops immediately when remote agent returns 'completed' status.
This test verifies the fix for issue #3899 where the server agent was ignoring
the 'completed' status and delegating the same request again, causing an infinite loop.
"""
a2a_config = A2AConfig(
endpoint="http://test-endpoint.com",
max_turns=10,
)
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
a2a=a2a_config,
)
task = Task(
description="Test task",
expected_output="Test output",
agent=agent,
)
final_message_text = "This is the final answer from the remote agent"
mock_history = [
Message(
role=Role.user,
message_id=str(uuid4()),
parts=[Part(root=TextPart(text="Initial request"))],
),
Message(
role=Role.agent,
message_id=str(uuid4()),
parts=[Part(root=TextPart(text=final_message_text))],
),
]
mock_a2a_result = {
"status": "completed",
"result": final_message_text,
"history": mock_history,
"agent_card": MagicMock(spec=AgentCard),
}
mock_agent_card = MagicMock(spec=AgentCard)
mock_agent_card.name = "Test Remote Agent"
mock_agent_card.url = "http://test-endpoint.com"
with patch("crewai.a2a.wrapper.execute_a2a_delegation") as mock_execute:
with patch("crewai.a2a.wrapper.fetch_agent_card", return_value=mock_agent_card):
with patch("crewai.a2a.wrapper._handle_agent_response_and_continue") as mock_handle:
mock_execute.return_value = mock_a2a_result
from crewai.a2a.wrapper import _delegate_to_a2a
mock_agent_response = Mock()
mock_agent_response.is_a2a = True
mock_agent_response.a2a_ids = ["http://test-endpoint.com/"]
mock_agent_response.message = "Please delegate this task"
result = _delegate_to_a2a(
self=agent,
agent_response=mock_agent_response,
task=task,
original_fn=Mock(),
context=None,
tools=None,
agent_cards={"http://test-endpoint.com/": mock_agent_card},
original_task_description="Test task",
)
assert mock_execute.call_count == 1, (
f"execute_a2a_delegation should be called exactly once, "
f"but was called {mock_execute.call_count} times"
)
assert mock_handle.call_count == 0, (
"_handle_agent_response_and_continue should NOT be called "
"when status is 'completed'"
)
assert result == final_message_text
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
def test_a2a_delegation_continues_on_input_required():
"""Test that A2A delegation continues when remote agent returns 'input_required' status.
This test verifies that the 'input_required' status still triggers the LLM
to decide on next steps, unlike 'completed' which should return immediately.
"""
a2a_config = A2AConfig(
endpoint="http://test-endpoint.com",
max_turns=10,
)
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
a2a=a2a_config,
)
task = Task(
description="Test task",
expected_output="Test output",
agent=agent,
)
mock_history_1 = [
Message(
role=Role.user,
message_id=str(uuid4()),
parts=[Part(root=TextPart(text="Initial request"))],
),
Message(
role=Role.agent,
message_id=str(uuid4()),
parts=[Part(root=TextPart(text="I need more information"))],
),
]
mock_history_2 = [
*mock_history_1,
Message(
role=Role.user,
message_id=str(uuid4()),
parts=[Part(root=TextPart(text="Here is the additional info"))],
),
Message(
role=Role.agent,
message_id=str(uuid4()),
parts=[Part(root=TextPart(text="Final answer with all info"))],
),
]
mock_a2a_result_1 = {
"status": "input_required",
"error": "I need more information",
"history": mock_history_1,
"agent_card": MagicMock(spec=AgentCard),
}
mock_a2a_result_2 = {
"status": "completed",
"result": "Final answer with all info",
"history": mock_history_2,
"agent_card": MagicMock(spec=AgentCard),
}
mock_agent_card = MagicMock(spec=AgentCard)
mock_agent_card.name = "Test Remote Agent"
mock_agent_card.url = "http://test-endpoint.com"
with patch("crewai.a2a.wrapper.execute_a2a_delegation") as mock_execute:
with patch("crewai.a2a.wrapper.fetch_agent_card", return_value=mock_agent_card):
with patch("crewai.a2a.wrapper._handle_agent_response_and_continue") as mock_handle:
mock_execute.side_effect = [mock_a2a_result_1, mock_a2a_result_2]
mock_handle.return_value = (None, "Here is the additional info")
from crewai.a2a.wrapper import _delegate_to_a2a
mock_agent_response = Mock()
mock_agent_response.is_a2a = True
mock_agent_response.a2a_ids = ["http://test-endpoint.com/"]
mock_agent_response.message = "Please delegate this task"
result = _delegate_to_a2a(
self=agent,
agent_response=mock_agent_response,
task=task,
original_fn=Mock(),
context=None,
tools=None,
agent_cards={"http://test-endpoint.com/": mock_agent_card},
original_task_description="Test task",
)
assert mock_execute.call_count == 2, (
f"execute_a2a_delegation should be called twice, "
f"but was called {mock_execute.call_count} times"
)
assert mock_handle.call_count == 1, (
"_handle_agent_response_and_continue should be called once "
"for 'input_required' status"
)
assert result == "Final answer with all info"
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
def test_a2a_delegation_completed_with_empty_history():
"""Test that A2A delegation handles 'completed' status with empty history gracefully.
This test verifies that when the remote agent returns 'completed' but the history
is empty or doesn't contain an agent message, we still return a reasonable result.
"""
a2a_config = A2AConfig(
endpoint="http://test-endpoint.com",
max_turns=10,
)
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
a2a=a2a_config,
)
task = Task(
description="Test task",
expected_output="Test output",
agent=agent,
)
mock_a2a_result = {
"status": "completed",
"result": "", # Empty result
"history": [], # Empty history
"agent_card": MagicMock(spec=AgentCard),
}
mock_agent_card = MagicMock(spec=AgentCard)
mock_agent_card.name = "Test Remote Agent"
mock_agent_card.url = "http://test-endpoint.com"
with patch("crewai.a2a.wrapper.execute_a2a_delegation") as mock_execute:
with patch("crewai.a2a.wrapper.fetch_agent_card", return_value=mock_agent_card):
with patch("crewai.a2a.wrapper._handle_agent_response_and_continue") as mock_handle:
mock_execute.return_value = mock_a2a_result
from crewai.a2a.wrapper import _delegate_to_a2a
mock_agent_response = Mock()
mock_agent_response.is_a2a = True
mock_agent_response.a2a_ids = ["http://test-endpoint.com/"]
mock_agent_response.message = "Please delegate this task"
result = _delegate_to_a2a(
self=agent,
agent_response=mock_agent_response,
task=task,
original_fn=Mock(),
context=None,
tools=None,
agent_cards={"http://test-endpoint.com/": mock_agent_card},
original_task_description="Test task",
)
assert mock_execute.call_count == 1
assert mock_handle.call_count == 0
assert result == "Conversation completed"
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
def test_a2a_delegation_completed_extracts_from_history():
"""Test that A2A delegation extracts final message from history when result is empty.
This test verifies that when the remote agent returns 'completed' with an empty result
but has messages in the history, we extract the final agent message from history.
"""
a2a_config = A2AConfig(
endpoint="http://test-endpoint.com",
max_turns=10,
)
agent = Agent(
role="Test Agent",
goal="Test goal",
backstory="Test backstory",
a2a=a2a_config,
)
task = Task(
description="Test task",
expected_output="Test output",
agent=agent,
)
final_message_text = "Final message from history"
mock_history = [
Message(
role=Role.user,
message_id=str(uuid4()),
parts=[Part(root=TextPart(text="Initial request"))],
),
Message(
role=Role.agent,
message_id=str(uuid4()),
parts=[Part(root=TextPart(text=final_message_text))],
),
]
mock_a2a_result = {
"status": "completed",
"result": "", # Empty result, should extract from history
"history": mock_history,
"agent_card": MagicMock(spec=AgentCard),
}
mock_agent_card = MagicMock(spec=AgentCard)
mock_agent_card.name = "Test Remote Agent"
mock_agent_card.url = "http://test-endpoint.com"
with patch("crewai.a2a.wrapper.execute_a2a_delegation") as mock_execute:
with patch("crewai.a2a.wrapper.fetch_agent_card", return_value=mock_agent_card):
with patch("crewai.a2a.wrapper._handle_agent_response_and_continue") as mock_handle:
mock_execute.return_value = mock_a2a_result
from crewai.a2a.wrapper import _delegate_to_a2a
mock_agent_response = Mock()
mock_agent_response.is_a2a = True
mock_agent_response.a2a_ids = ["http://test-endpoint.com/"]
mock_agent_response.message = "Please delegate this task"
result = _delegate_to_a2a(
self=agent,
agent_response=mock_agent_response,
task=task,
original_fn=Mock(),
context=None,
tools=None,
agent_cards={"http://test-endpoint.com/": mock_agent_card},
original_task_description="Test task",
)
assert mock_execute.call_count == 1
assert mock_handle.call_count == 0
assert result == final_message_text

8251
uv.lock generated

File diff suppressed because it is too large Load Diff