diff --git a/src/google/adk/a2a/converters/from_adk_event.py b/src/google/adk/a2a/converters/from_adk_event.py index d64c1940fa..b5bb260014 100644 --- a/src/google/adk/a2a/converters/from_adk_event.py +++ b/src/google/adk/a2a/converters/from_adk_event.py @@ -17,6 +17,7 @@ from collections.abc import Callable from datetime import datetime from datetime import timezone +import json import logging from typing import Any from typing import Dict @@ -266,6 +267,27 @@ def _serialize_value(value: Any) -> Optional[Any]: logger.warning("Failed to serialize Pydantic model, falling back: %s", e) return str(value) + # JSON-native leaf types — return as-is + if isinstance(value, (str, bool, int, float)): + return value + + # Containers — recurse so nested non-serializable values are handled + if isinstance(value, dict): + return {str(k): _serialize_value(v) for k, v in value.items()} + + if isinstance(value, (list, tuple)): + return [_serialize_value(item) for item in value] + + # Common Python types with no JSON equivalent + if isinstance(value, (set, frozenset)): + return [_serialize_value(item) for item in sorted(value, key=str)] + + # Other objects — try JSON normalization, then str() fallback + try: + return json.loads(json.dumps(value, default=str)) + except (TypeError, ValueError): + pass + return str(value) diff --git a/tests/unittests/a2a/converters/test_event_round_trip.py b/tests/unittests/a2a/converters/test_event_round_trip.py index 00036f6af7..40a7d8c1d7 100644 --- a/tests/unittests/a2a/converters/test_event_round_trip.py +++ b/tests/unittests/a2a/converters/test_event_round_trip.py @@ -23,8 +23,10 @@ from a2a.types import TaskStatusUpdateEvent from google.adk.a2a.converters.from_adk_event import convert_event_to_a2a_events from google.adk.a2a.converters.from_adk_event import create_error_status_event +from google.adk.a2a.converters.to_adk_event import _parse_adk_metadata_value from google.adk.a2a.converters.to_adk_event import convert_a2a_artifact_update_to_event from google.adk.a2a.converters.to_adk_event import convert_a2a_status_update_to_event +from google.adk.a2a.converters.utils import _get_adk_metadata_key from google.adk.agents.invocation_context import InvocationContext from google.adk.events.event import Event from google.genai import types as genai_types @@ -206,3 +208,83 @@ def test_round_trip_function_response_event(): assert restored_event.content.parts[0].function_response.response == { "result": "success" } + + +def test_round_trip_custom_metadata_preserves_structured_values(): + original_custom_metadata = { + "flag": True, + "count": 42, + "nested": {"key": "val"}, + "tags": ["a", "b"], + } + original_event = Event( + invocation_id="test_invocation", + author="test_agent", + branch="main", + content=genai_types.Content( + role="model", + parts=[genai_types.Part.from_text(text="Hello world!")], + ), + custom_metadata=original_custom_metadata, + ) + agents_artifacts: Dict[str, str] = {} + + a2a_events = convert_event_to_a2a_events( + event=original_event, + agents_artifacts=agents_artifacts, + task_id="task1", + context_id="context1", + ) + + assert len(a2a_events) == 1 + a2a_event = a2a_events[0] + assert isinstance(a2a_event, TaskArtifactUpdateEvent) + + serialized_metadata = a2a_event.artifact.metadata[ + _get_adk_metadata_key("custom_metadata") + ] + + assert not isinstance(serialized_metadata, str) + assert ( + _parse_adk_metadata_value(serialized_metadata) == original_custom_metadata + ) + + +def test_serialize_value_handles_non_serializable_nested_types(): + """Regression: non-JSON-native types inside dicts/lists must not crash.""" + from datetime import datetime + from datetime import timezone + + from google.adk.a2a.converters.from_adk_event import _serialize_value + + ts = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + value = { + "created_at": ts, + "tags": {"alpha", "beta"}, + "normal": 42, + "nested_list": [True, ts], + } + + result = _serialize_value(value) + + # Result must be fully JSON-serializable (no crash) + import json + + json_str = json.dumps(result) + parsed = json.loads(json_str) + + # Leaf types preserved + assert parsed["normal"] == 42 + + # datetime falls back to str representation + assert isinstance(parsed["created_at"], str) + assert "2026" in parsed["created_at"] + + # set becomes a sorted list of strings + assert isinstance(parsed["tags"], list) + assert set(parsed["tags"]) == {"alpha", "beta"} + + # nested list with mixed types + assert parsed["nested_list"][0] is True + assert isinstance(parsed["nested_list"][1], str) +