Skip to content

Commit 2e3d717

Browse files
giulio-leoneGWeale
authored andcommitted
fix: guard against None converter results in RemoteA2aAgent handlers
Merge google#5219 ## Summary Fixes google#5187 — `AttributeError: 'NoneType' object has no attribute 'custom_metadata'` crash in `RemoteA2aAgent` when converter functions return `None`. ## Root Cause Both `_handle_a2a_response()` (legacy) and `_handle_a2a_response_v2()` (v2) dereference converter results (e.g. `event.custom_metadata`, `event.content`) without checking for `None`. The v2-default converters in `to_adk_event.py` legitimately return `None` via `_create_event()` when there are no convertible parts and no event actions — this is by design for metadata-only updates, empty status changes, etc. ## Fix Add `if not event: return None` guards after each converter call site: | Handler | Path | Guard added | |---------|------|------------| | Legacy (`_handle_a2a_response`) | Task converter — no status update | ✅ | | Legacy (`_handle_a2a_response`) | Message converter — status update with message | ✅ | | Legacy (`_handle_a2a_response`) | Task converter — artifact update | ✅ | | Legacy (`_handle_a2a_response`) | A2AMessage response | ✅ | | V2 (`_handle_a2a_response_v2`) | A2AMessage response | ✅ | | V2 (`_handle_a2a_response_v2`) | Tuple response | Already guarded (line 572) | Returning `None` 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`. ## Testing - Reproduced both crashes before the fix - Verified both crashes are resolved after the fix - Added 8 regression tests in `TestRemoteA2aAgentNoneConverterResults` covering all converter paths in both handlers - All 103 tests pass (95 existing + 8 new), 0 failures Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=google#5219 from giulio-leone:fix/remote-a2a-none-converter-crash 21f4de7 PiperOrigin-RevId: 937711023
1 parent 6a5be34 commit 2e3d717

2 files changed

Lines changed: 201 additions & 0 deletions

File tree

src/google/adk/agents/remote_a2a_agent.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,8 @@ async def _handle_a2a_response(
508508
event = convert_a2a_task_to_event(
509509
task, self.name, ctx, self._a2a_part_converter
510510
)
511+
if not event:
512+
return None
511513
# for streaming task, we update the event with the task status.
512514
# We update the event as Thought updates.
513515
if (
@@ -555,6 +557,8 @@ async def _handle_a2a_response(
555557
event = convert_a2a_task_to_event(
556558
task, self.name, ctx, self._a2a_part_converter
557559
)
560+
if not event:
561+
return None
558562
else:
559563
# This is a streaming update without a message (e.g. status change)
560564
# or a partial artifact update. We don't emit an event for these

tests/unittests/agents/test_remote_a2a_agent.py

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

20132013

2014+
class TestRemoteA2aAgentNoneConverterResults:
2015+
"""Regression tests for None converter results in both legacy and v2 handlers.
2016+
2017+
Converters can legitimately return None for messages/tasks with no convertible
2018+
parts, metadata-only events, or empty status updates. The handlers must not
2019+
crash with AttributeError when this happens.
2020+
"""
2021+
2022+
def setup_method(self):
2023+
"""Setup test fixtures."""
2024+
from google.adk.a2a.agent.config import A2aRemoteAgentConfig
2025+
2026+
self.agent_card = create_test_agent_card()
2027+
2028+
# Legacy handler agent
2029+
self.mock_a2a_part_converter = Mock()
2030+
self.legacy_agent = RemoteA2aAgent(
2031+
name="test_agent",
2032+
agent_card=self.agent_card,
2033+
a2a_part_converter=self.mock_a2a_part_converter,
2034+
)
2035+
2036+
# V2 handler agent
2037+
self.mock_config = Mock(spec=A2aRemoteAgentConfig)
2038+
self.mock_config.a2a_part_converter = Mock()
2039+
self.mock_config.a2a_task_converter = Mock()
2040+
self.mock_config.a2a_status_update_converter = Mock()
2041+
self.mock_config.a2a_artifact_update_converter = Mock()
2042+
self.mock_config.a2a_message_converter = Mock()
2043+
self.mock_config.request_interceptors = None
2044+
self.v2_agent = RemoteA2aAgent(
2045+
name="test_agent",
2046+
agent_card=self.agent_card,
2047+
config=self.mock_config,
2048+
)
2049+
2050+
# Shared mock context
2051+
self.mock_session = Mock(spec=Session)
2052+
self.mock_session.id = "session-123"
2053+
self.mock_session.events = []
2054+
2055+
self.mock_context = Mock(spec=InvocationContext)
2056+
self.mock_context.session = self.mock_session
2057+
self.mock_context.invocation_id = "invocation-123"
2058+
self.mock_context.branch = "main"
2059+
2060+
# --- V2 handler regression tests ---
2061+
2062+
@pytest.mark.asyncio
2063+
async def test_v2_message_converter_returns_none(self):
2064+
"""V2 handler must not crash when message converter returns None."""
2065+
mock_msg = Mock(spec=A2AMessage)
2066+
mock_msg.metadata = {}
2067+
mock_msg.context_id = None
2068+
2069+
self.mock_config.a2a_message_converter.return_value = None
2070+
2071+
result = await self.v2_agent._handle_a2a_response_v2(
2072+
mock_msg, self.mock_context
2073+
)
2074+
2075+
assert result is None
2076+
self.mock_config.a2a_message_converter.assert_called_once()
2077+
2078+
@pytest.mark.asyncio
2079+
async def test_v2_message_converter_returns_none_with_context_id(self):
2080+
"""V2 handler returns None even when message has a context_id."""
2081+
mock_msg = Mock(spec=A2AMessage)
2082+
mock_msg.metadata = {}
2083+
mock_msg.context_id = "ctx-should-not-be-accessed"
2084+
2085+
self.mock_config.a2a_message_converter.return_value = None
2086+
2087+
result = await self.v2_agent._handle_a2a_response_v2(
2088+
mock_msg, self.mock_context
2089+
)
2090+
2091+
assert result is None
2092+
2093+
@pytest.mark.asyncio
2094+
async def test_v2_task_converter_returns_none(self):
2095+
"""V2 handler must not crash when task converter returns None."""
2096+
mock_task = Mock(spec=A2ATask)
2097+
mock_task.id = "task-123"
2098+
mock_task.context_id = "ctx-123"
2099+
2100+
self.mock_config.a2a_task_converter.return_value = None
2101+
2102+
result = await self.v2_agent._handle_a2a_response_v2(
2103+
(mock_task, None), self.mock_context
2104+
)
2105+
2106+
assert result is None
2107+
2108+
@pytest.mark.asyncio
2109+
async def test_v2_status_update_converter_returns_none(self):
2110+
"""V2 handler must not crash when status update converter returns None."""
2111+
mock_task = Mock(spec=A2ATask)
2112+
mock_task.id = "task-123"
2113+
mock_task.context_id = None
2114+
2115+
mock_update = Mock(spec=TaskStatusUpdateEvent)
2116+
2117+
self.mock_config.a2a_status_update_converter.return_value = None
2118+
2119+
result = await self.v2_agent._handle_a2a_response_v2(
2120+
(mock_task, mock_update), self.mock_context
2121+
)
2122+
2123+
assert result is None
2124+
2125+
# --- Legacy handler regression tests ---
2126+
2127+
@pytest.mark.asyncio
2128+
async def test_legacy_message_converter_returns_none(self):
2129+
"""Legacy handler must not crash when message converter returns None."""
2130+
mock_msg = Mock(spec=A2AMessage)
2131+
mock_msg.context_id = "context-123"
2132+
2133+
with patch(
2134+
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
2135+
) as mock_convert:
2136+
mock_convert.return_value = None
2137+
2138+
result = await self.legacy_agent._handle_a2a_response(
2139+
mock_msg, self.mock_context
2140+
)
2141+
2142+
assert result is None
2143+
mock_convert.assert_called_once()
2144+
2145+
@pytest.mark.asyncio
2146+
async def test_legacy_task_converter_returns_none_no_update(self):
2147+
"""Legacy handler must not crash when task converter returns None (no update)."""
2148+
mock_task = Mock(spec=A2ATask)
2149+
mock_task.id = "task-123"
2150+
mock_task.context_id = None
2151+
mock_task.status = Mock()
2152+
mock_task.status.state = TaskState.completed
2153+
2154+
with patch(
2155+
"google.adk.agents.remote_a2a_agent.convert_a2a_task_to_event"
2156+
) as mock_convert:
2157+
mock_convert.return_value = None
2158+
2159+
result = await self.legacy_agent._handle_a2a_response(
2160+
(mock_task, None), self.mock_context
2161+
)
2162+
2163+
assert result is None
2164+
2165+
@pytest.mark.asyncio
2166+
async def test_legacy_message_converter_returns_none_status_update(self):
2167+
"""Legacy handler must not crash when message converter returns None for status update."""
2168+
mock_task = Mock(spec=A2ATask)
2169+
mock_task.id = "task-123"
2170+
mock_task.context_id = "ctx-123"
2171+
2172+
mock_update = Mock(spec=TaskStatusUpdateEvent)
2173+
mock_update.status = Mock()
2174+
mock_update.status.message = Mock()
2175+
mock_update.status.state = TaskState.working
2176+
2177+
with patch(
2178+
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
2179+
) as mock_convert:
2180+
mock_convert.return_value = None
2181+
2182+
result = await self.legacy_agent._handle_a2a_response(
2183+
(mock_task, mock_update), self.mock_context
2184+
)
2185+
2186+
assert result is None
2187+
2188+
@pytest.mark.asyncio
2189+
async def test_legacy_task_converter_returns_none_artifact_update(self):
2190+
"""Legacy handler must not crash when task converter returns None for artifact update."""
2191+
mock_task = Mock(spec=A2ATask)
2192+
mock_task.id = "task-123"
2193+
mock_task.context_id = None
2194+
2195+
mock_update = Mock(spec=TaskArtifactUpdateEvent)
2196+
mock_update.append = False
2197+
mock_update.last_chunk = True
2198+
2199+
with patch(
2200+
"google.adk.agents.remote_a2a_agent.convert_a2a_task_to_event"
2201+
) as mock_convert:
2202+
mock_convert.return_value = None
2203+
2204+
result = await self.legacy_agent._handle_a2a_response(
2205+
(mock_task, mock_update), self.mock_context
2206+
)
2207+
2208+
assert result is None
2209+
2210+
20142211
class TestRemoteA2aAgentExecution:
20152212
"""Test agent execution functionality."""
20162213

0 commit comments

Comments
 (0)