From bc8a9d6e5e30c8d72eb5825229c1bdfb41a24eb5 Mon Sep 17 00:00:00 2001 From: Giulio Leone Date: Thu, 9 Apr 2026 02:58:20 +0200 Subject: [PATCH] fix: restore event metadata in A2A inbound converters The inbound converters (convert_a2a_message_to_event, convert_a2a_task_to_event, convert_a2a_status_update_to_event, convert_a2a_artifact_update_to_event) only restored 'actions' from A2A metadata. Other event metadata fields (custom_metadata, usage_metadata, error_code) serialized by the outbound converter were silently dropped. Additionally, _create_event() returned None for metadata-only messages (no parts, no actions) even when valid event metadata was present. Changes: - Add _extract_event_metadata() to restore custom_metadata, error_code, and usage_metadata from A2A metadata. - Update _create_event() to accept and set these fields, and preserve events that carry only metadata (no parts/actions). - Update all four converter functions to extract and forward metadata. - Add 9 regression tests covering metadata restoration across all converter functions and the metadata-only event case. Fixes google/adk-python#5185 --- src/google/adk/a2a/converters/to_adk_event.py | 90 ++++++- tests/unittests/a2a/converters/test_to_adk.py | 246 ++++++++++++++++++ 2 files changed, 333 insertions(+), 3 deletions(-) diff --git a/src/google/adk/a2a/converters/to_adk_event.py b/src/google/adk/a2a/converters/to_adk_event.py index 26ae95e1b4..7c538b008e 100644 --- a/src/google/adk/a2a/converters/to_adk_event.py +++ b/src/google/adk/a2a/converters/to_adk_event.py @@ -177,12 +177,23 @@ def _create_event( actions: Optional[EventActions] = None, long_running_function_ids: Optional[set[str]] = None, partial: bool = False, + custom_metadata: Optional[dict[str, Any]] = None, + error_code: Optional[str] = None, + usage_metadata: Optional[ + genai_types.GenerateContentResponseUsageMetadata + ] = None, ) -> Optional[Event]: """Creates an ADK event from parts and metadata.""" event_actions = actions or EventActions() - if not output_parts and not event_actions.model_dump( - exclude_none=True, exclude_defaults=True - ): + has_actions = bool( + event_actions.model_dump(exclude_none=True, exclude_defaults=True) + ) + has_event_metadata = ( + custom_metadata is not None + or error_code is not None + or usage_metadata is not None + ) + if not output_parts and not has_actions and not has_event_metadata: return None event = Event( @@ -206,6 +217,9 @@ def _create_event( else None ), partial=partial, + custom_metadata=custom_metadata, + error_code=error_code, + usage_metadata=usage_metadata, ) return event @@ -248,6 +262,59 @@ def _extract_event_actions( return EventActions() +def _extract_event_metadata( + metadata: Optional[dict[str, Any]], +) -> dict[str, Any]: + """Extracts ADK event metadata fields from A2A metadata. + + Restores custom_metadata, error_code, and usage_metadata that were + serialized by the outbound converter. + + Args: + metadata: The A2A metadata dictionary. + + Returns: + A dict of keyword arguments suitable for passing to _create_event(). + """ + if not metadata: + return {} + + result: dict[str, Any] = {} + + raw_custom = metadata.get(_get_adk_metadata_key("custom_metadata")) + if raw_custom is not None: + parsed = _parse_adk_metadata_value(raw_custom) + if isinstance(parsed, dict): + result["custom_metadata"] = parsed + else: + logger.warning( + "Ignoring invalid ADK custom_metadata of type %s", + type(parsed).__name__, + ) + + raw_error_code = metadata.get(_get_adk_metadata_key("error_code")) + if raw_error_code is not None: + result["error_code"] = str(raw_error_code) + + raw_usage = metadata.get(_get_adk_metadata_key("usage_metadata")) + if raw_usage is not None: + parsed = _parse_adk_metadata_value(raw_usage) + if isinstance(parsed, dict): + try: + result["usage_metadata"] = ( + genai_types.GenerateContentResponseUsageMetadata(**parsed) + ) + except Exception as e: + logger.warning("Ignoring invalid ADK usage_metadata: %s", e) + else: + logger.warning( + "Ignoring invalid ADK usage_metadata of type %s", + type(parsed).__name__, + ) + + return result + + def _merge_top_level_dicts( base: dict[str, Any], new_values: dict[str, Any] ) -> dict[str, Any]: @@ -304,6 +371,7 @@ def convert_a2a_task_to_event( try: event_actions = EventActions() + event_metadata: dict[str, Any] = {} output_parts = [] long_running_function_ids = set() if a2a_task.artifacts: @@ -314,6 +382,7 @@ def convert_a2a_task_to_event( event_actions = _merge_event_actions( event_actions, _extract_event_actions(artifact.metadata) ) + event_metadata.update(_extract_event_metadata(artifact.metadata)) output_parts, _ = _convert_a2a_parts_to_adk_parts( artifact_parts, part_converter ) @@ -325,6 +394,9 @@ def convert_a2a_task_to_event( event_actions, _extract_event_actions(a2a_task.status.message.metadata), ) + event_metadata.update( + _extract_event_metadata(a2a_task.status.message.metadata) + ) parts, ids = _convert_a2a_parts_to_adk_parts( a2a_task.status.message.parts, part_converter ) @@ -337,6 +409,7 @@ def convert_a2a_task_to_event( author, event_actions, long_running_function_ids, + **event_metadata, ) except Exception as e: @@ -375,11 +448,13 @@ def convert_a2a_message_to_event( output_parts, _ = _convert_a2a_parts_to_adk_parts( a2a_message.parts, part_converter ) + event_metadata = _extract_event_metadata(a2a_message.metadata) return _create_event( output_parts, invocation_context, author, _extract_event_actions(a2a_message.metadata), + **event_metadata, ) except Exception as e: @@ -412,10 +487,14 @@ def convert_a2a_status_update_to_event( output_parts = [] long_running_function_ids = set() event_actions = EventActions() + event_metadata: dict[str, Any] = {} if a2a_status_update.status.message: event_actions = _extract_event_actions( a2a_status_update.status.message.metadata ) + event_metadata = _extract_event_metadata( + a2a_status_update.status.message.metadata + ) parts, ids = _convert_a2a_parts_to_adk_parts( a2a_status_update.status.message.parts, part_converter ) @@ -428,6 +507,7 @@ def convert_a2a_status_update_to_event( author, event_actions, long_running_function_ids, + **event_metadata, ) except Exception as e: logger.error("Failed to convert A2A status update to event: %s", e) @@ -460,12 +540,16 @@ def convert_a2a_artifact_update_to_event( output_parts, _ = _convert_a2a_parts_to_adk_parts( a2a_artifact_update.artifact.parts, part_converter ) + event_metadata = _extract_event_metadata( + a2a_artifact_update.artifact.metadata + ) return _create_event( output_parts, invocation_context, author, _extract_event_actions(a2a_artifact_update.artifact.metadata), partial=not a2a_artifact_update.last_chunk, + **event_metadata, ) except Exception as e: logger.error("Failed to convert A2A artifact update to event: %s", e) diff --git a/tests/unittests/a2a/converters/test_to_adk.py b/tests/unittests/a2a/converters/test_to_adk.py index 12eaf2a75a..70367f032e 100644 --- a/tests/unittests/a2a/converters/test_to_adk.py +++ b/tests/unittests/a2a/converters/test_to_adk.py @@ -418,3 +418,249 @@ def test_convert_a2a_artifact_update_to_event_none(self): """Test convert_a2a_artifact_update_to_event with None.""" with pytest.raises(ValueError, match="A2A artifact update cannot be None"): convert_a2a_artifact_update_to_event(None) + + # --- Regression tests for issue #5185 --- + + def test_convert_a2a_message_metadata_only_returns_event(self): + """Metadata-only message (no parts, no actions) must not return None.""" + message = Message( + message_id="msg-1", + role="agent", + parts=[], + metadata={ + _get_adk_metadata_key("custom_metadata"): {"flag": True} + }, + ) + + event = convert_a2a_message_to_event( + message, + author="agent", + invocation_context=self.mock_context, + part_converter=Mock(), + ) + + assert event is not None + assert event.content is None + assert event.custom_metadata == {"flag": True} + + def test_convert_a2a_message_restores_custom_metadata(self): + """custom_metadata must be restored on content-bearing events.""" + a2a_part = Mock(spec=A2APart) + a2a_part.root = Mock(spec=TextPart) + a2a_part.root.metadata = {} + message = Message( + message_id="msg-1", + role="agent", + parts=[a2a_part], + metadata={ + _get_adk_metadata_key("custom_metadata"): { + "trace_id": "abc-123", + "score": 0.95, + } + }, + ) + + mock_genai_part = genai_types.Part.from_text(text="hello") + event = convert_a2a_message_to_event( + message, + author="agent", + invocation_context=self.mock_context, + part_converter=Mock(return_value=[mock_genai_part]), + ) + + assert event is not None + assert event.custom_metadata == {"trace_id": "abc-123", "score": 0.95} + assert event.content is not None + + def test_convert_a2a_message_restores_error_code(self): + """error_code must be restored from A2A metadata.""" + message = Message( + message_id="msg-1", + role="agent", + parts=[], + metadata={ + _get_adk_metadata_key("error_code"): "RESOURCE_EXHAUSTED" + }, + ) + + event = convert_a2a_message_to_event( + message, + author="agent", + invocation_context=self.mock_context, + part_converter=Mock(), + ) + + assert event is not None + assert event.error_code == "RESOURCE_EXHAUSTED" + assert event.content is None + + def test_convert_a2a_message_restores_usage_metadata(self): + """usage_metadata must be restored from A2A metadata.""" + usage_dict = {"promptTokenCount": 10, "candidatesTokenCount": 20} + message = Message( + message_id="msg-1", + role="agent", + parts=[], + metadata={ + _get_adk_metadata_key("usage_metadata"): usage_dict, + }, + ) + + event = convert_a2a_message_to_event( + message, + author="agent", + invocation_context=self.mock_context, + part_converter=Mock(), + ) + + assert event is not None + assert event.usage_metadata is not None + assert event.usage_metadata.prompt_token_count == 10 + assert event.usage_metadata.candidates_token_count == 20 + + def test_convert_a2a_message_restores_all_metadata_fields(self): + """All metadata fields must be restored together.""" + message = Message( + message_id="msg-1", + role="agent", + parts=[], + metadata={ + _get_adk_metadata_key("custom_metadata"): {"key": "value"}, + _get_adk_metadata_key("error_code"): "INVALID_ARGUMENT", + _get_adk_metadata_key("usage_metadata"): { + "promptTokenCount": 5, + }, + _get_adk_metadata_key("actions"): { + "stateDelta": {"x": 1} + }, + }, + ) + + event = convert_a2a_message_to_event( + message, + author="agent", + invocation_context=self.mock_context, + part_converter=Mock(), + ) + + assert event is not None + assert event.custom_metadata == {"key": "value"} + assert event.error_code == "INVALID_ARGUMENT" + assert event.usage_metadata.prompt_token_count == 5 + assert event.actions.state_delta == {"x": 1} + + def test_convert_a2a_message_no_parts_no_metadata_returns_none(self): + """Empty message with no parts, no actions, no metadata still returns None.""" + message = Message( + message_id="msg-1", + role="agent", + parts=[], + ) + + event = convert_a2a_message_to_event( + message, + author="agent", + invocation_context=self.mock_context, + part_converter=Mock(), + ) + + assert event is None + + def test_convert_a2a_task_restores_metadata_from_artifact(self): + """Task conversion must restore event metadata from artifact metadata.""" + task = Task( + id="task-1", + status=TaskStatus( + state=TaskState.submitted, timestamp="2024-01-01T00:00:00Z" + ), + context_id="context-1", + artifacts=[ + Artifact( + artifact_id="art-1", + artifact_type="message", + parts=[], + metadata={ + _get_adk_metadata_key("custom_metadata"): { + "source": "agent-x" + }, + _get_adk_metadata_key("error_code"): "TIMEOUT", + }, + ) + ], + ) + + event = convert_a2a_task_to_event( + task, + author="test-author", + invocation_context=self.mock_context, + part_converter=Mock(), + ) + + assert event is not None + assert event.custom_metadata == {"source": "agent-x"} + assert event.error_code == "TIMEOUT" + assert event.content is None + + def test_convert_a2a_status_update_restores_metadata(self): + """Status update conversion must restore event metadata.""" + update = TaskStatusUpdateEvent( + task_id="task-1", + status=TaskStatus( + state=TaskState.working, + timestamp="now", + message=Message( + message_id="m1", + role="agent", + parts=[], + metadata={ + _get_adk_metadata_key("custom_metadata"): { + "progress": 50 + }, + }, + ), + ), + context_id="context-1", + final=False, + ) + + event = convert_a2a_status_update_to_event( + update, + author="test-author", + invocation_context=self.mock_context, + part_converter=Mock(), + ) + + assert event is not None + assert event.custom_metadata == {"progress": 50} + + def test_convert_a2a_artifact_update_restores_metadata(self): + """Artifact update conversion must restore event metadata.""" + a2a_part = Mock(spec=A2APart) + a2a_part.root = Mock(spec=TextPart) + a2a_part.root.metadata = {} + update = TaskArtifactUpdateEvent( + task_id="task-1", + artifact=Artifact( + artifact_id="art-1", + artifact_type="message", + parts=[a2a_part], + metadata={ + _get_adk_metadata_key("custom_metadata"): {"tag": "v1"}, + }, + ), + append=False, + context_id="context-1", + last_chunk=True, + ) + + mock_genai_part = genai_types.Part.from_text(text="artifact text") + event = convert_a2a_artifact_update_to_event( + update, + author="test-author", + invocation_context=self.mock_context, + part_converter=Mock(return_value=[mock_genai_part]), + ) + + assert event is not None + assert event.custom_metadata == {"tag": "v1"} + assert event.content is not None