Skip to content

Commit d7bdac1

Browse files
feat: a2a trust remote completion status flag
- add trust_remote_completion_status flag to A2AConfig, Adds configuration flag to control whether to trust A2A agent completion status. Resolves #3899 - update docs
1 parent 528d812 commit d7bdac1

File tree

4 files changed

+175
-2
lines changed

4 files changed

+175
-2
lines changed

docs/en/learn/a2a-agent-delegation.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ The `A2AConfig` class accepts the following parameters:
8383
Whether to raise an error immediately if agent connection fails. When `False`, the agent continues with available agents and informs the LLM about unavailable ones.
8484
</ParamField>
8585

86+
<ParamField path="trust_remote_completion_status" type="bool" default="False">
87+
When `True`, returns the A2A agent's result directly when it signals completion. When `False`, allows the server agent to review the result and potentially continue the conversation.
88+
</ParamField>
89+
8690
## Authentication
8791

8892
For A2A agents that require authentication, use one of the provided auth schemes:

lib/crewai/src/crewai/a2a/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class A2AConfig(BaseModel):
3838
max_turns: Maximum conversation turns with A2A agent (default: 10).
3939
response_model: Optional Pydantic model for structured A2A agent responses.
4040
fail_fast: If True, raise error when agent unreachable; if False, skip and continue (default: True).
41+
trust_remote_completion_status: If True, return A2A agent's result directly when status is "completed"; if False, always ask server agent to respond (default: False).
4142
"""
4243

4344
endpoint: Url = Field(description="A2A agent endpoint URL")
@@ -57,3 +58,7 @@ class A2AConfig(BaseModel):
5758
default=True,
5859
description="If True, raise an error immediately when the A2A agent is unreachable. If False, skip the A2A agent and continue execution.",
5960
)
61+
trust_remote_completion_status: bool = Field(
62+
default=False,
63+
description='If True, return the A2A agent\'s result directly when status is "completed" without asking the server agent to respond. If False, always ask the server agent to respond, allowing it to potentially delegate again.',
64+
)

lib/crewai/src/crewai/a2a/wrapper.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def wrap_agent_with_a2a_instance(agent: Agent) -> None:
5252
Args:
5353
agent: The agent instance to wrap
5454
"""
55-
original_execute_task = agent.execute_task.__func__
55+
original_execute_task = agent.execute_task.__func__ # type: ignore[attr-defined]
5656

5757
@wraps(original_execute_task)
5858
def execute_task_with_a2a(
@@ -73,7 +73,7 @@ def execute_task_with_a2a(
7373
Task execution result
7474
"""
7575
if not self.a2a:
76-
return original_execute_task(self, task, context, tools)
76+
return original_execute_task(self, task, context, tools) # type: ignore[no-any-return]
7777

7878
a2a_agents, agent_response_model = get_a2a_agents_and_response_model(self.a2a)
7979

@@ -498,6 +498,23 @@ def _delegate_to_a2a(
498498
conversation_history = a2a_result.get("history", [])
499499

500500
if a2a_result["status"] in ["completed", "input_required"]:
501+
if (
502+
a2a_result["status"] == "completed"
503+
and agent_config.trust_remote_completion_status
504+
):
505+
result_text = a2a_result.get("result", "")
506+
final_turn_number = turn_num + 1
507+
crewai_event_bus.emit(
508+
None,
509+
A2AConversationCompletedEvent(
510+
status="completed",
511+
final_result=result_text,
512+
error=None,
513+
total_turns=final_turn_number,
514+
),
515+
)
516+
return result_text # type: ignore[no-any-return]
517+
501518
final_result, next_request = _handle_agent_response_and_continue(
502519
self=self,
503520
a2a_result=a2a_result,
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Test trust_remote_completion_status flag in A2A wrapper."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
7+
from crewai.a2a.config import A2AConfig
8+
9+
try:
10+
from a2a.types import Message, Role
11+
12+
A2A_SDK_INSTALLED = True
13+
except ImportError:
14+
A2A_SDK_INSTALLED = False
15+
16+
17+
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
18+
def test_trust_remote_completion_status_true_returns_directly():
19+
"""When trust_remote_completion_status=True and A2A returns completed, return result directly."""
20+
from crewai.a2a.wrapper import _delegate_to_a2a
21+
from crewai.a2a.types import AgentResponseProtocol
22+
from crewai import Agent, Task
23+
24+
a2a_config = A2AConfig(
25+
endpoint="http://test-endpoint.com",
26+
trust_remote_completion_status=True,
27+
)
28+
29+
agent = Agent(
30+
role="test manager",
31+
goal="coordinate",
32+
backstory="test",
33+
a2a=a2a_config,
34+
)
35+
36+
task = Task(description="test", expected_output="test", agent=agent)
37+
38+
class MockResponse:
39+
is_a2a = True
40+
message = "Please help"
41+
a2a_ids = ["http://test-endpoint.com/"]
42+
43+
with (
44+
patch("crewai.a2a.wrapper.execute_a2a_delegation") as mock_execute,
45+
patch("crewai.a2a.wrapper._fetch_agent_cards_concurrently") as mock_fetch,
46+
):
47+
mock_card = MagicMock()
48+
mock_card.name = "Test"
49+
mock_fetch.return_value = ({"http://test-endpoint.com/": mock_card}, {})
50+
51+
# A2A returns completed
52+
mock_execute.return_value = {
53+
"status": "completed",
54+
"result": "Done by remote",
55+
"history": [],
56+
}
57+
58+
# This should return directly without checking LLM response
59+
result = _delegate_to_a2a(
60+
self=agent,
61+
agent_response=MockResponse(),
62+
task=task,
63+
original_fn=lambda *args, **kwargs: "fallback",
64+
context=None,
65+
tools=None,
66+
agent_cards={"http://test-endpoint.com/": mock_card},
67+
original_task_description="test",
68+
)
69+
70+
assert result == "Done by remote"
71+
assert mock_execute.call_count == 1
72+
73+
74+
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
75+
def test_trust_remote_completion_status_false_continues_conversation():
76+
"""When trust_remote_completion_status=False and A2A returns completed, ask server agent."""
77+
from crewai.a2a.wrapper import _delegate_to_a2a
78+
from crewai import Agent, Task
79+
80+
a2a_config = A2AConfig(
81+
endpoint="http://test-endpoint.com",
82+
trust_remote_completion_status=False,
83+
)
84+
85+
agent = Agent(
86+
role="test manager",
87+
goal="coordinate",
88+
backstory="test",
89+
a2a=a2a_config,
90+
)
91+
92+
task = Task(description="test", expected_output="test", agent=agent)
93+
94+
class MockResponse:
95+
is_a2a = True
96+
message = "Please help"
97+
a2a_ids = ["http://test-endpoint.com/"]
98+
99+
call_count = 0
100+
101+
def mock_original_fn(self, task, context, tools):
102+
nonlocal call_count
103+
call_count += 1
104+
if call_count == 1:
105+
# Server decides to finish
106+
return '{"is_a2a": false, "message": "Server final answer", "a2a_ids": []}'
107+
return "unexpected"
108+
109+
with (
110+
patch("crewai.a2a.wrapper.execute_a2a_delegation") as mock_execute,
111+
patch("crewai.a2a.wrapper._fetch_agent_cards_concurrently") as mock_fetch,
112+
):
113+
mock_card = MagicMock()
114+
mock_card.name = "Test"
115+
mock_fetch.return_value = ({"http://test-endpoint.com/": mock_card}, {})
116+
117+
# A2A returns completed
118+
mock_execute.return_value = {
119+
"status": "completed",
120+
"result": "Done by remote",
121+
"history": [],
122+
}
123+
124+
result = _delegate_to_a2a(
125+
self=agent,
126+
agent_response=MockResponse(),
127+
task=task,
128+
original_fn=mock_original_fn,
129+
context=None,
130+
tools=None,
131+
agent_cards={"http://test-endpoint.com/": mock_card},
132+
original_task_description="test",
133+
)
134+
135+
# Should call original_fn to get server response
136+
assert call_count >= 1
137+
assert result == "Server final answer"
138+
139+
140+
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
141+
def test_default_trust_remote_completion_status_is_false():
142+
"""Verify that default value of trust_remote_completion_status is False."""
143+
a2a_config = A2AConfig(
144+
endpoint="http://test-endpoint.com",
145+
)
146+
147+
assert a2a_config.trust_remote_completion_status is False

0 commit comments

Comments
 (0)