Skip to content

Commit 285dbb3

Browse files
giulio-leoneCopilot
andcommitted
fix: guard against None converter results in RemoteA2aAgent handlers
Both _handle_a2a_response() and _handle_a2a_response_v2() dereference converter results (accessing event.content, event.custom_metadata) without guarding against None. Converters can legitimately return None for messages with no convertible parts, metadata-only events, or empty status updates. Add None guards after each converter call in both handlers: - Legacy handler: 3 guards (task-no-update, status-update-message, artifact-update paths) + 1 guard (A2AMessage path) - V2 handler: 1 guard (A2AMessage path; tuple path was already guarded) The fix returns None (skip event) which is consistent with the existing pattern in the v2 tuple branch and is properly handled by the caller in _run_async_impl via 'if not event: continue'. Fixes #5187 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0fedb3b commit 285dbb3

File tree

2 files changed

+207
-0
lines changed

2 files changed

+207
-0
lines changed

src/google/adk/agents/remote_a2a_agent.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,8 @@ async def _handle_a2a_response(
452452
event = convert_a2a_task_to_event(
453453
task, self.name, ctx, self._a2a_part_converter
454454
)
455+
if not event:
456+
return None
455457
# for streaming task, we update the event with the task status.
456458
# We update the event as Thought updates.
457459
if (
@@ -476,6 +478,8 @@ async def _handle_a2a_response(
476478
event = convert_a2a_message_to_event(
477479
update.status.message, self.name, ctx, self._a2a_part_converter
478480
)
481+
if not event:
482+
return None
479483
if event.content is not None and update.status.state in (
480484
TaskState.submitted,
481485
TaskState.working,
@@ -495,6 +499,8 @@ async def _handle_a2a_response(
495499
event = convert_a2a_task_to_event(
496500
task, self.name, ctx, self._a2a_part_converter
497501
)
502+
if not event:
503+
return None
498504
else:
499505
# This is a streaming update without a message (e.g. status change)
500506
# or a partial artifact update. We don't emit an event for these
@@ -513,6 +519,8 @@ async def _handle_a2a_response(
513519
event = convert_a2a_message_to_event(
514520
a2a_response, self.name, ctx, self._a2a_part_converter
515521
)
522+
if not event:
523+
return None
516524
event.custom_metadata = event.custom_metadata or {}
517525

518526
if a2a_response.context_id:
@@ -583,6 +591,8 @@ async def _handle_a2a_response_v2(
583591
event = self._config.a2a_message_converter(
584592
a2a_response, self.name, ctx, self._config.a2a_part_converter
585593
)
594+
if not event:
595+
return None
586596
event.custom_metadata = event.custom_metadata or {}
587597

588598
if a2a_response.context_id:

tests/unittests/agents/test_remote_a2a_agent.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1922,6 +1922,203 @@ async def test_handle_a2a_response_impl_handles_client_error(self):
19221922
assert result.branch == self.mock_context.branch
19231923

19241924

1925+
class TestRemoteA2aAgentNoneConverterResults:
1926+
"""Regression tests for None converter results in both legacy and v2 handlers.
1927+
1928+
Converters can legitimately return None for messages/tasks with no convertible
1929+
parts, metadata-only events, or empty status updates. The handlers must not
1930+
crash with AttributeError when this happens.
1931+
"""
1932+
1933+
def setup_method(self):
1934+
"""Setup test fixtures."""
1935+
from google.adk.a2a.agent.config import A2aRemoteAgentConfig
1936+
1937+
self.agent_card = create_test_agent_card()
1938+
1939+
# Legacy handler agent
1940+
self.mock_a2a_part_converter = Mock()
1941+
self.legacy_agent = RemoteA2aAgent(
1942+
name="test_agent",
1943+
agent_card=self.agent_card,
1944+
a2a_part_converter=self.mock_a2a_part_converter,
1945+
)
1946+
1947+
# V2 handler agent
1948+
self.mock_config = Mock(spec=A2aRemoteAgentConfig)
1949+
self.mock_config.a2a_part_converter = Mock()
1950+
self.mock_config.a2a_task_converter = Mock()
1951+
self.mock_config.a2a_status_update_converter = Mock()
1952+
self.mock_config.a2a_artifact_update_converter = Mock()
1953+
self.mock_config.a2a_message_converter = Mock()
1954+
self.mock_config.request_interceptors = None
1955+
self.v2_agent = RemoteA2aAgent(
1956+
name="test_agent",
1957+
agent_card=self.agent_card,
1958+
config=self.mock_config,
1959+
)
1960+
1961+
# Shared mock context
1962+
self.mock_session = Mock(spec=Session)
1963+
self.mock_session.id = "session-123"
1964+
self.mock_session.events = []
1965+
1966+
self.mock_context = Mock(spec=InvocationContext)
1967+
self.mock_context.session = self.mock_session
1968+
self.mock_context.invocation_id = "invocation-123"
1969+
self.mock_context.branch = "main"
1970+
1971+
# --- V2 handler regression tests ---
1972+
1973+
@pytest.mark.asyncio
1974+
async def test_v2_message_converter_returns_none(self):
1975+
"""V2 handler must not crash when message converter returns None."""
1976+
mock_msg = Mock(spec=A2AMessage)
1977+
mock_msg.metadata = {}
1978+
mock_msg.context_id = None
1979+
1980+
self.mock_config.a2a_message_converter.return_value = None
1981+
1982+
result = await self.v2_agent._handle_a2a_response_v2(
1983+
mock_msg, self.mock_context
1984+
)
1985+
1986+
assert result is None
1987+
self.mock_config.a2a_message_converter.assert_called_once()
1988+
1989+
@pytest.mark.asyncio
1990+
async def test_v2_message_converter_returns_none_with_context_id(self):
1991+
"""V2 handler returns None even when message has a context_id."""
1992+
mock_msg = Mock(spec=A2AMessage)
1993+
mock_msg.metadata = {}
1994+
mock_msg.context_id = "ctx-should-not-be-accessed"
1995+
1996+
self.mock_config.a2a_message_converter.return_value = None
1997+
1998+
result = await self.v2_agent._handle_a2a_response_v2(
1999+
mock_msg, self.mock_context
2000+
)
2001+
2002+
assert result is None
2003+
2004+
@pytest.mark.asyncio
2005+
async def test_v2_task_converter_returns_none(self):
2006+
"""V2 handler must not crash when task converter returns None."""
2007+
mock_task = Mock(spec=A2ATask)
2008+
mock_task.id = "task-123"
2009+
mock_task.context_id = "ctx-123"
2010+
2011+
self.mock_config.a2a_task_converter.return_value = None
2012+
2013+
result = await self.v2_agent._handle_a2a_response_v2(
2014+
(mock_task, None), self.mock_context
2015+
)
2016+
2017+
assert result is None
2018+
2019+
@pytest.mark.asyncio
2020+
async def test_v2_status_update_converter_returns_none(self):
2021+
"""V2 handler must not crash when status update converter returns None."""
2022+
mock_task = Mock(spec=A2ATask)
2023+
mock_task.id = "task-123"
2024+
mock_task.context_id = None
2025+
2026+
mock_update = Mock(spec=TaskStatusUpdateEvent)
2027+
2028+
self.mock_config.a2a_status_update_converter.return_value = None
2029+
2030+
result = await self.v2_agent._handle_a2a_response_v2(
2031+
(mock_task, mock_update), self.mock_context
2032+
)
2033+
2034+
assert result is None
2035+
2036+
# --- Legacy handler regression tests ---
2037+
2038+
@pytest.mark.asyncio
2039+
async def test_legacy_message_converter_returns_none(self):
2040+
"""Legacy handler must not crash when message converter returns None."""
2041+
mock_msg = Mock(spec=A2AMessage)
2042+
mock_msg.context_id = "context-123"
2043+
2044+
with patch(
2045+
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
2046+
) as mock_convert:
2047+
mock_convert.return_value = None
2048+
2049+
result = await self.legacy_agent._handle_a2a_response(
2050+
mock_msg, self.mock_context
2051+
)
2052+
2053+
assert result is None
2054+
mock_convert.assert_called_once()
2055+
2056+
@pytest.mark.asyncio
2057+
async def test_legacy_task_converter_returns_none_no_update(self):
2058+
"""Legacy handler must not crash when task converter returns None (no update)."""
2059+
mock_task = Mock(spec=A2ATask)
2060+
mock_task.id = "task-123"
2061+
mock_task.context_id = None
2062+
mock_task.status = Mock()
2063+
mock_task.status.state = TaskState.completed
2064+
2065+
with patch(
2066+
"google.adk.agents.remote_a2a_agent.convert_a2a_task_to_event"
2067+
) as mock_convert:
2068+
mock_convert.return_value = None
2069+
2070+
result = await self.legacy_agent._handle_a2a_response(
2071+
(mock_task, None), self.mock_context
2072+
)
2073+
2074+
assert result is None
2075+
2076+
@pytest.mark.asyncio
2077+
async def test_legacy_message_converter_returns_none_status_update(self):
2078+
"""Legacy handler must not crash when message converter returns None for status update."""
2079+
mock_task = Mock(spec=A2ATask)
2080+
mock_task.id = "task-123"
2081+
mock_task.context_id = "ctx-123"
2082+
2083+
mock_update = Mock(spec=TaskStatusUpdateEvent)
2084+
mock_update.status = Mock()
2085+
mock_update.status.message = Mock()
2086+
mock_update.status.state = TaskState.working
2087+
2088+
with patch(
2089+
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
2090+
) as mock_convert:
2091+
mock_convert.return_value = None
2092+
2093+
result = await self.legacy_agent._handle_a2a_response(
2094+
(mock_task, mock_update), self.mock_context
2095+
)
2096+
2097+
assert result is None
2098+
2099+
@pytest.mark.asyncio
2100+
async def test_legacy_task_converter_returns_none_artifact_update(self):
2101+
"""Legacy handler must not crash when task converter returns None for artifact update."""
2102+
mock_task = Mock(spec=A2ATask)
2103+
mock_task.id = "task-123"
2104+
mock_task.context_id = None
2105+
2106+
mock_update = Mock(spec=TaskArtifactUpdateEvent)
2107+
mock_update.append = False
2108+
mock_update.last_chunk = True
2109+
2110+
with patch(
2111+
"google.adk.agents.remote_a2a_agent.convert_a2a_task_to_event"
2112+
) as mock_convert:
2113+
mock_convert.return_value = None
2114+
2115+
result = await self.legacy_agent._handle_a2a_response(
2116+
(mock_task, mock_update), self.mock_context
2117+
)
2118+
2119+
assert result is None
2120+
2121+
19252122
class TestRemoteA2aAgentExecution:
19262123
"""Test agent execution functionality."""
19272124

0 commit comments

Comments
 (0)