Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion contributing/samples/gepa/experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@
from tau_bench.types import EnvRunResult
from tau_bench.types import RunConfig
import tau_bench_agent as tau_bench_agent_lib

import utils


Expand Down
1 change: 0 additions & 1 deletion contributing/samples/gepa/run_experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
from absl import flags
import experiment
from google.genai import types

import utils

_OUTPUT_DIR = flags.DEFINE_string(
Expand Down
8 changes: 8 additions & 0 deletions src/google/adk/agents/remote_a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,14 @@ async def _handle_a2a_response(
invocation_id=ctx.invocation_id,
branch=ctx.branch,
)
# Filter out thought parts from user-facing response content.
# Intermediate (submitted/working) events have all parts marked as
# thought, so non_thought_parts will be empty and we preserve them.
if event.content is not None and event.content.parts:
non_thought_parts = [p for p in event.content.parts if not p.thought]
if non_thought_parts:
event.content.parts = non_thought_parts

return event
except A2AClientError as e:
logger.error("Failed to handle A2A response: %s", e)
Expand Down
94 changes: 94 additions & 0 deletions tests/unittests/a2a/converters/test_part_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,70 @@ def test_convert_text_part(self):
assert isinstance(result, genai_types.Part)
assert result.text == "Hello, world!"

def test_convert_text_part_with_thought_metadata(self):
"""Test conversion of A2A TextPart with adk_thought metadata to GenAI Part.

Verifies that the inbound conversion restores thought=True from A2A
metadata, which is essential for the thought-filtering logic in
RemoteA2aAgent._handle_a2a_response. See #4676.
"""
# Arrange
a2a_part = a2a_types.Part(
root=a2a_types.TextPart(
text="internal reasoning",
metadata={_get_adk_metadata_key("thought"): True},
)
)

# Act
result = convert_a2a_part_to_genai_part(a2a_part)

# Assert
assert result is not None
assert isinstance(result, genai_types.Part)
assert result.text == "internal reasoning"
assert result.thought is True

def test_convert_text_part_with_thought_false_metadata(self):
"""Test conversion of A2A TextPart with adk_thought=False metadata."""
# Arrange
a2a_part = a2a_types.Part(
root=a2a_types.TextPart(
text="user-facing text",
metadata={_get_adk_metadata_key("thought"): False},
)
)

# Act
result = convert_a2a_part_to_genai_part(a2a_part)

# Assert
assert result is not None
assert result.text == "user-facing text"
# thought=False means it's not a thought part; the filter won't match
assert result.thought is False

def test_convert_text_part_without_thought_metadata(self):
"""Test conversion of A2A TextPart without adk_thought metadata.

When no thought metadata is present, thought should remain None.
"""
# Arrange
a2a_part = a2a_types.Part(
root=a2a_types.TextPart(
text="regular text",
metadata={"some_other_key": "value"},
)
)

# Act
result = convert_a2a_part_to_genai_part(a2a_part)

# Assert
assert result is not None
assert result.text == "regular text"
assert result.thought is None

def test_convert_file_part_with_uri(self):
"""Test conversion of A2A FilePart with URI to GenAI Part."""
# Arrange
Expand Down Expand Up @@ -545,6 +609,36 @@ def test_text_part_with_thought_round_trip(self):
assert result_genai_part.text == original_text
assert result_genai_part.thought

def test_thought_round_trip_enables_filtering(self):
"""Test that thought round-trip enables downstream filtering.

This reproduces the exact scenario from #4676: an A2A response
contains both thought and non-thought parts serialized as artifacts.
After round-tripping through part_converter, the thought flag must
be preserved so that filtering (e.g., in RemoteA2aAgent) can remove
thought parts from user-facing output.
"""
# Simulate outbound: GenAI parts -> A2A parts (server side)
thought_genai = genai_types.Part(
text="<internal reasoning text>", thought=True
)
answer_genai = genai_types.Part(text="<final user-facing answer>")

thought_a2a = convert_genai_part_to_a2a_part(thought_genai)
answer_a2a = convert_genai_part_to_a2a_part(answer_genai)

# Simulate inbound: A2A parts -> GenAI parts (client side)
restored_thought = convert_a2a_part_to_genai_part(thought_a2a)
restored_answer = convert_a2a_part_to_genai_part(answer_a2a)

# Apply the filter that RemoteA2aAgent uses
parts = [restored_thought, restored_answer]
filtered = [p for p in parts if not p.thought]

# Only the user-facing answer should survive
assert len(filtered) == 1
assert filtered[0].text == "<final user-facing answer>"

def test_file_uri_round_trip(self):
"""Test round-trip conversion for file parts with URI."""
# Arrange
Expand Down
157 changes: 157 additions & 0 deletions tests/unittests/agents/test_remote_a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,163 @@ async def test_handle_a2a_response_with_partial_artifact_update(self):

assert result is None

@pytest.mark.asyncio
async def test_handle_a2a_response_filters_thought_parts_from_completed_task(
self,
):
"""Test that thought parts are filtered from completed task response.

When an A2A server returns a completed task with both thought and
non-thought parts, the client should only include non-thought parts
in the user-facing event. Fixes #4676.
"""
mock_a2a_task = Mock(spec=A2ATask)
mock_a2a_task.id = "task-123"
mock_a2a_task.context_id = "context-123"
mock_a2a_task.status = Mock(spec=A2ATaskStatus)
mock_a2a_task.status.state = TaskState.completed

# Create event with mixed thought/non-thought parts
thought_part = genai_types.Part(text="internal reasoning", thought=True)
answer_part = genai_types.Part(text="final answer")
mock_event = Event(
author=self.agent.name,
invocation_id=self.mock_context.invocation_id,
branch=self.mock_context.branch,
content=genai_types.Content(
role="model", parts=[thought_part, answer_part]
),
)

with patch.object(
remote_a2a_agent,
"convert_a2a_task_to_event",
autospec=True,
) as mock_convert:
mock_convert.return_value = mock_event

result = await self.agent._handle_a2a_response(
(mock_a2a_task, None), self.mock_context
)

# Only non-thought parts should remain
assert len(result.content.parts) == 1
assert result.content.parts[0].text == "final answer"
assert result.content.parts[0].thought is None

@pytest.mark.asyncio
async def test_handle_a2a_response_filters_thought_parts_from_status_update(
self,
):
"""Test that thought parts are filtered from completed status update.

Fixes #4676.
"""
mock_a2a_task = Mock(spec=A2ATask)
mock_a2a_task.id = "task-123"
mock_a2a_task.context_id = "context-123"

mock_update = Mock(spec=TaskStatusUpdateEvent)
mock_update.status = Mock(spec=A2ATaskStatus)
mock_update.status.state = TaskState.completed
mock_update.status.message = Mock(spec=A2AMessage)

# Create event with mixed thought/non-thought parts
thought_part = genai_types.Part(text="thinking...", thought=True)
answer_part = genai_types.Part(text="the answer")
mock_event = Event(
author=self.agent.name,
invocation_id=self.mock_context.invocation_id,
branch=self.mock_context.branch,
content=genai_types.Content(
role="model", parts=[thought_part, answer_part]
),
)

with patch(
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
) as mock_convert:
mock_convert.return_value = mock_event

result = await self.agent._handle_a2a_response(
(mock_a2a_task, mock_update), self.mock_context
)

# Only non-thought parts should remain
assert len(result.content.parts) == 1
assert result.content.parts[0].text == "the answer"

@pytest.mark.asyncio
async def test_handle_a2a_response_preserves_all_thought_parts_for_working(
self,
):
"""Test that working state events keep all parts as thoughts.

Intermediate events (working/submitted) should retain all parts
marked as thought for streaming progress display.
"""
mock_a2a_task = Mock(spec=A2ATask)
mock_a2a_task.id = "task-123"
mock_a2a_task.context_id = "context-123"
mock_a2a_task.status = Mock(spec=A2ATaskStatus)
mock_a2a_task.status.state = TaskState.working

part = genai_types.Part(text="still thinking")
mock_event = Event(
author=self.agent.name,
invocation_id=self.mock_context.invocation_id,
branch=self.mock_context.branch,
content=genai_types.Content(role="model", parts=[part]),
)

with patch.object(
remote_a2a_agent,
"convert_a2a_task_to_event",
autospec=True,
) as mock_convert:
mock_convert.return_value = mock_event

result = await self.agent._handle_a2a_response(
(mock_a2a_task, None), self.mock_context
)

# All parts should be marked as thought and preserved
assert len(result.content.parts) == 1
assert result.content.parts[0].thought is True

@pytest.mark.asyncio
async def test_handle_a2a_response_filters_thought_from_a2a_message(self):
"""Test thought filtering for regular A2AMessage responses.

Fixes #4676.
"""
mock_a2a_message = Mock(spec=A2AMessage)
mock_a2a_message.context_id = "context-123"

thought_part = genai_types.Part(text="reasoning", thought=True)
answer_part = genai_types.Part(text="response")
mock_event = Event(
author=self.agent.name,
invocation_id=self.mock_context.invocation_id,
branch=self.mock_context.branch,
content=genai_types.Content(
role="model", parts=[thought_part, answer_part]
),
)

with patch(
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
) as mock_convert:
mock_convert.return_value = mock_event

result = await self.agent._handle_a2a_response(
mock_a2a_message, self.mock_context
)

# Only non-thought parts should remain
assert len(result.content.parts) == 1
assert result.content.parts[0].text == "response"


class TestRemoteA2aAgentMessageHandlingFromFactory:
"""Test message handling functionality."""
Expand Down