From 778eb1b459a33f7051368466e9facab3f2b9c5ce Mon Sep 17 00:00:00 2001 From: Ryne Cheow Date: Tue, 9 Jun 2026 09:21:58 +0800 Subject: [PATCH] fix(sessions): drop unsupported part_metadata before Vertex appendEvent google.genai Part.part_metadata is a Gemini Developer API-only field (the model path already guards it in genai's _Part_to_vertex). VertexAiSessionService. append_event serialised it into both the `content` and `raw_event` payloads, and the Vertex AI Agent Engine Sessions appendEvent API rejects it with 400 INVALID_ARGUMENT ('Unknown name "part_metadata" at event.content.parts[0]'), crashing every turn for agents that carry part_metadata (e.g. ADK's A2A part converter copies inbound A2A part metadata into Part.part_metadata). Strip part_metadata from the serialised content (and raw_event content) before the appendEvent call. Adds regression tests for both the stripped append payload and the read-back round-trip via get_session. Fixes #6014 --- .../adk/sessions/vertex_ai_session_service.py | 23 +++++- .../test_vertex_ai_session_service.py | 80 +++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/google/adk/sessions/vertex_ai_session_service.py b/src/google/adk/sessions/vertex_ai_session_service.py index d673c845fa..17c10dfcf5 100644 --- a/src/google/adk/sessions/vertex_ai_session_service.py +++ b/src/google/adk/sessions/vertex_ai_session_service.py @@ -78,6 +78,21 @@ def _set_internal_custom_metadata( } +def _drop_vertex_unsupported_part_fields(content_dict: dict[str, Any]) -> None: + """Drops Part fields the Vertex AI Agent Engine Sessions API rejects. + + ``part_metadata`` is a Gemini Developer API-only field (the model path guards + it in ``genai`` ``_Part_to_vertex``); the Agent Engine Sessions API does not + accept it and fails ``appendEvent`` with ``400 INVALID_ARGUMENT`` ("Unknown + name \"part_metadata\" at 'event.content.parts[0]'"). Mutates the serialized + content dict in place; tolerant of either field-name or alias serialization. + """ + for part in content_dict.get('parts') or []: + if isinstance(part, dict): + part.pop('part_metadata', None) + part.pop('partMetadata', None) + + class VertexAiSessionService(BaseSessionService): """Connects to the Vertex AI Agent Engine Session Service using Agent Engine SDK. @@ -339,9 +354,9 @@ async def append_event(self, session: Session, event: Event) -> Event: # Build config (Monolithic approach) config = {} if event.content: - config['content'] = event.content.model_dump( - exclude_none=True, mode='json' - ) + content_dict = event.content.model_dump(exclude_none=True, mode='json') + _drop_vertex_unsupported_part_fields(content_dict) + config['content'] = content_dict if event.actions: config['actions'] = { 'skip_summarization': event.actions.skip_summarization, @@ -406,6 +421,8 @@ async def append_event(self, session: Session, event: Event) -> Event: mode='json', by_alias=True, ) + if isinstance(config['raw_event'].get('content'), dict): + _drop_vertex_unsupported_part_fields(config['raw_event']['content']) # Retry without raw_event if client side validation fails for older SDK # versions. diff --git a/tests/unittests/sessions/test_vertex_ai_session_service.py b/tests/unittests/sessions/test_vertex_ai_session_service.py index b8c71701dc..c55e71cddd 100644 --- a/tests/unittests/sessions/test_vertex_ai_session_service.py +++ b/tests/unittests/sessions/test_vertex_ai_session_service.py @@ -1088,6 +1088,86 @@ async def test_append_event(): assert retrieved_session.events[1] == event_to_append +@pytest.mark.asyncio +@pytest.mark.usefixtures('mock_get_api_client') +async def test_append_event_strips_unsupported_part_metadata( + mock_api_client_instance: MockAsyncClient, +) -> None: + """part_metadata must not reach the Sessions API (#6014). + + ``Part.part_metadata`` is a Gemini Developer API-only field; the Vertex AI + Agent Engine Sessions ``appendEvent`` API rejects it with 400 INVALID_ARGUMENT + ("Unknown name \"part_metadata\""). It must be dropped from both the + ``content`` and ``raw_event`` payloads, while the part text is preserved. + """ + session_service = mock_vertex_ai_session_service() + session = await session_service.get_session( + app_name='123', user_id='user', session_id='1' + ) + event_to_append = Event( + invocation_id='inv_part_metadata', + author='user', + timestamp=1734005533.0, + content=genai_types.Content( + parts=[ + genai_types.Part( + text='hello', part_metadata={'source': 'portal'} + ), + genai_types.Part(text='world', part_metadata={'n': 1}), + ], + ), + ) + + await session_service.append_event(session, event_to_append) + + appended = mock_api_client_instance.event_dict['1'][0][-1] + for part in appended['content']['parts']: + assert 'part_metadata' not in part + assert 'partMetadata' not in part + for part in appended['raw_event']['content']['parts']: + assert 'part_metadata' not in part + assert 'partMetadata' not in part + assert [p['text'] for p in appended['content']['parts']] == ['hello', 'world'] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures('mock_get_api_client') +async def test_append_event_with_part_metadata_round_trips( + mock_api_client_instance: MockAsyncClient, +) -> None: + """Reconstruction side of #6014: an event carrying part_metadata appends and + reads back without error. part_metadata is dropped (unsupported on Vertex), + but the session round-trips and the part text is preserved. + """ + session_service = mock_vertex_ai_session_service() + session = await session_service.get_session( + app_name='123', user_id='user', session_id='1' + ) + event_to_append = Event( + invocation_id='inv_part_metadata_rt', + author='user', + timestamp=1734005533.0, + content=genai_types.Content( + role='user', + parts=[ + genai_types.Part(text='hello', part_metadata={'source': 'portal'}) + ], + ), + ) + + await session_service.append_event(session, event_to_append) + retrieved = await session_service.get_session( + app_name='123', user_id='user', session_id='1' + ) + + appended = next( + e for e in retrieved.events if e.invocation_id == 'inv_part_metadata_rt' + ) + assert appended.content is not None + assert appended.content.parts[0].text == 'hello' + assert appended.content.parts[0].part_metadata is None + + @pytest.mark.asyncio @pytest.mark.usefixtures('mock_get_api_client') async def test_append_event_with_compaction():