Skip to content

Commit e322381

Browse files
committed
fix: filter thought parts from A2A client user-facing response
When an A2A server returns a completed response containing both thought parts (metadata.adk_thought=true) and final answer parts, the client now filters out thought parts before yielding the event to consumers. Intermediate (submitted/working) events are preserved as-is since all their parts are already marked as thoughts for streaming progress. Fixes #4676
1 parent 36e76b9 commit e322381

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

src/google/adk/agents/remote_a2a_agent.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,19 @@ async def _handle_a2a_response(
514514
invocation_id=ctx.invocation_id,
515515
branch=ctx.branch,
516516
)
517+
# Filter out thought parts from user-facing response content.
518+
# Intermediate (submitted/working) events have all parts marked as
519+
# thought, so non_thought_parts will be empty and we preserve them.
520+
if (
521+
event.content is not None
522+
and event.content.parts
523+
):
524+
non_thought_parts = [
525+
p for p in event.content.parts if not p.thought
526+
]
527+
if non_thought_parts:
528+
event.content.parts = non_thought_parts
529+
517530
return event
518531
except A2AClientError as e:
519532
logger.error("Failed to handle A2A response: %s", e)

tests/unittests/agents/test_remote_a2a_agent.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,163 @@ async def test_handle_a2a_response_with_partial_artifact_update(self):
12781278

12791279
assert result is None
12801280

1281+
@pytest.mark.asyncio
1282+
async def test_handle_a2a_response_filters_thought_parts_from_completed_task(
1283+
self,
1284+
):
1285+
"""Test that thought parts are filtered from completed task response.
1286+
1287+
When an A2A server returns a completed task with both thought and
1288+
non-thought parts, the client should only include non-thought parts
1289+
in the user-facing event. Fixes #4676.
1290+
"""
1291+
mock_a2a_task = Mock(spec=A2ATask)
1292+
mock_a2a_task.id = "task-123"
1293+
mock_a2a_task.context_id = "context-123"
1294+
mock_a2a_task.status = Mock(spec=A2ATaskStatus)
1295+
mock_a2a_task.status.state = TaskState.completed
1296+
1297+
# Create event with mixed thought/non-thought parts
1298+
thought_part = genai_types.Part(text="internal reasoning", thought=True)
1299+
answer_part = genai_types.Part(text="final answer")
1300+
mock_event = Event(
1301+
author=self.agent.name,
1302+
invocation_id=self.mock_context.invocation_id,
1303+
branch=self.mock_context.branch,
1304+
content=genai_types.Content(
1305+
role="model", parts=[thought_part, answer_part]
1306+
),
1307+
)
1308+
1309+
with patch.object(
1310+
remote_a2a_agent,
1311+
"convert_a2a_task_to_event",
1312+
autospec=True,
1313+
) as mock_convert:
1314+
mock_convert.return_value = mock_event
1315+
1316+
result = await self.agent._handle_a2a_response(
1317+
(mock_a2a_task, None), self.mock_context
1318+
)
1319+
1320+
# Only non-thought parts should remain
1321+
assert len(result.content.parts) == 1
1322+
assert result.content.parts[0].text == "final answer"
1323+
assert result.content.parts[0].thought is None
1324+
1325+
@pytest.mark.asyncio
1326+
async def test_handle_a2a_response_filters_thought_parts_from_status_update(
1327+
self,
1328+
):
1329+
"""Test that thought parts are filtered from completed status update.
1330+
1331+
Fixes #4676.
1332+
"""
1333+
mock_a2a_task = Mock(spec=A2ATask)
1334+
mock_a2a_task.id = "task-123"
1335+
mock_a2a_task.context_id = "context-123"
1336+
1337+
mock_update = Mock(spec=TaskStatusUpdateEvent)
1338+
mock_update.status = Mock(spec=A2ATaskStatus)
1339+
mock_update.status.state = TaskState.completed
1340+
mock_update.status.message = Mock(spec=A2AMessage)
1341+
1342+
# Create event with mixed thought/non-thought parts
1343+
thought_part = genai_types.Part(text="thinking...", thought=True)
1344+
answer_part = genai_types.Part(text="the answer")
1345+
mock_event = Event(
1346+
author=self.agent.name,
1347+
invocation_id=self.mock_context.invocation_id,
1348+
branch=self.mock_context.branch,
1349+
content=genai_types.Content(
1350+
role="model", parts=[thought_part, answer_part]
1351+
),
1352+
)
1353+
1354+
with patch(
1355+
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
1356+
) as mock_convert:
1357+
mock_convert.return_value = mock_event
1358+
1359+
result = await self.agent._handle_a2a_response(
1360+
(mock_a2a_task, mock_update), self.mock_context
1361+
)
1362+
1363+
# Only non-thought parts should remain
1364+
assert len(result.content.parts) == 1
1365+
assert result.content.parts[0].text == "the answer"
1366+
1367+
@pytest.mark.asyncio
1368+
async def test_handle_a2a_response_preserves_all_thought_parts_for_working(
1369+
self,
1370+
):
1371+
"""Test that working state events keep all parts as thoughts.
1372+
1373+
Intermediate events (working/submitted) should retain all parts
1374+
marked as thought for streaming progress display.
1375+
"""
1376+
mock_a2a_task = Mock(spec=A2ATask)
1377+
mock_a2a_task.id = "task-123"
1378+
mock_a2a_task.context_id = "context-123"
1379+
mock_a2a_task.status = Mock(spec=A2ATaskStatus)
1380+
mock_a2a_task.status.state = TaskState.working
1381+
1382+
part = genai_types.Part(text="still thinking")
1383+
mock_event = Event(
1384+
author=self.agent.name,
1385+
invocation_id=self.mock_context.invocation_id,
1386+
branch=self.mock_context.branch,
1387+
content=genai_types.Content(role="model", parts=[part]),
1388+
)
1389+
1390+
with patch.object(
1391+
remote_a2a_agent,
1392+
"convert_a2a_task_to_event",
1393+
autospec=True,
1394+
) as mock_convert:
1395+
mock_convert.return_value = mock_event
1396+
1397+
result = await self.agent._handle_a2a_response(
1398+
(mock_a2a_task, None), self.mock_context
1399+
)
1400+
1401+
# All parts should be marked as thought and preserved
1402+
assert len(result.content.parts) == 1
1403+
assert result.content.parts[0].thought is True
1404+
1405+
@pytest.mark.asyncio
1406+
async def test_handle_a2a_response_filters_thought_from_a2a_message(self):
1407+
"""Test thought filtering for regular A2AMessage responses.
1408+
1409+
Fixes #4676.
1410+
"""
1411+
mock_a2a_message = Mock(spec=A2AMessage)
1412+
mock_a2a_message.context_id = "context-123"
1413+
1414+
thought_part = genai_types.Part(text="reasoning", thought=True)
1415+
answer_part = genai_types.Part(text="response")
1416+
mock_event = Event(
1417+
author=self.agent.name,
1418+
invocation_id=self.mock_context.invocation_id,
1419+
branch=self.mock_context.branch,
1420+
content=genai_types.Content(
1421+
role="model", parts=[thought_part, answer_part]
1422+
),
1423+
)
1424+
1425+
with patch(
1426+
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
1427+
) as mock_convert:
1428+
mock_convert.return_value = mock_event
1429+
1430+
result = await self.agent._handle_a2a_response(
1431+
mock_a2a_message, self.mock_context
1432+
)
1433+
1434+
# Only non-thought parts should remain
1435+
assert len(result.content.parts) == 1
1436+
assert result.content.parts[0].text == "response"
1437+
12811438

12821439
class TestRemoteA2aAgentMessageHandlingFromFactory:
12831440
"""Test message handling functionality."""

0 commit comments

Comments
 (0)