Skip to content

Commit 82d7aa8

Browse files
committed
Fix A2A inbound converters to restore all event metadata fields
The inbound converters in to_adk_event.py only restored `actions` from A2A metadata, silently dropping `custom_metadata`, `usage_metadata`, `error_code`, and `grounding_metadata`. Additionally, `_create_event()` returned None for metadata-only messages with no parts or actions. Add `_extract_event_metadata()` to restore these fields from A2A metadata and update `_create_event()` to accept and apply them. Metadata-only messages now correctly produce a valid Event.
1 parent 114deef commit 82d7aa8

2 files changed

Lines changed: 249 additions & 5 deletions

File tree

src/google/adk/a2a/converters/to_adk_event.py

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,17 @@ def _create_event(
177177
actions: Optional[EventActions] = None,
178178
long_running_function_ids: Optional[set[str]] = None,
179179
partial: bool = False,
180+
event_metadata: Optional[dict[str, Any]] = None,
180181
) -> Optional[Event]:
181182
"""Creates an ADK event from parts and metadata."""
182183
event_actions = actions or EventActions()
183-
if not output_parts and not event_actions.model_dump(
184-
exclude_none=True, exclude_defaults=True
185-
):
184+
has_actions = bool(
185+
event_actions.model_dump(exclude_none=True, exclude_defaults=True)
186+
)
187+
if not output_parts and not has_actions and not event_metadata:
186188
return None
187189

188-
event = Event(
190+
event_kwargs: dict[str, Any] = dict(
189191
invocation_id=(
190192
invocation_context.invocation_id
191193
if invocation_context
@@ -208,7 +210,10 @@ def _create_event(
208210
partial=partial,
209211
)
210212

211-
return event
213+
if event_metadata:
214+
event_kwargs.update(event_metadata)
215+
216+
return Event(**event_kwargs)
212217

213218

214219
def _parse_adk_metadata_value(value: Any) -> Any:
@@ -248,6 +253,74 @@ def _extract_event_actions(
248253
return EventActions()
249254

250255

256+
def _extract_event_metadata(
257+
metadata: Optional[dict[str, Any]],
258+
) -> dict[str, Any]:
259+
"""Extracts ADK event metadata fields from A2A metadata.
260+
261+
Restores fields like custom_metadata, usage_metadata, error_code, and
262+
grounding_metadata that were serialized into A2A metadata by the outbound
263+
converter.
264+
265+
Args:
266+
metadata: The A2A metadata dictionary.
267+
268+
Returns:
269+
A dictionary of event keyword arguments to apply to the Event.
270+
"""
271+
if not metadata:
272+
return {}
273+
274+
event_kwargs: dict[str, Any] = {}
275+
276+
# Simple fields that can be restored directly
277+
simple_fields = ["error_code"]
278+
for field_name in simple_fields:
279+
raw_value = metadata.get(_get_adk_metadata_key(field_name))
280+
if raw_value is not None:
281+
event_kwargs[field_name] = _parse_adk_metadata_value(raw_value)
282+
283+
# Dict fields that need parsing
284+
dict_fields = ["custom_metadata"]
285+
for field_name in dict_fields:
286+
raw_value = metadata.get(_get_adk_metadata_key(field_name))
287+
if raw_value is not None:
288+
parsed = _parse_adk_metadata_value(raw_value)
289+
if isinstance(parsed, dict):
290+
event_kwargs[field_name] = parsed
291+
else:
292+
logger.warning(
293+
"Ignoring invalid ADK %s metadata of type %s",
294+
field_name,
295+
type(parsed).__name__,
296+
)
297+
298+
# Pydantic model fields that need validation
299+
model_fields = {
300+
"usage_metadata": genai_types.GenerateContentResponseUsageMetadata,
301+
"grounding_metadata": genai_types.GroundingMetadata,
302+
}
303+
for field_name, model_class in model_fields.items():
304+
raw_value = metadata.get(_get_adk_metadata_key(field_name))
305+
if raw_value is not None:
306+
parsed = _parse_adk_metadata_value(raw_value)
307+
if isinstance(parsed, dict):
308+
try:
309+
event_kwargs[field_name] = model_class(**parsed)
310+
except (ValidationError, TypeError) as error:
311+
logger.warning(
312+
"Ignoring invalid ADK %s metadata: %s", field_name, error
313+
)
314+
else:
315+
logger.warning(
316+
"Ignoring invalid ADK %s metadata of type %s",
317+
field_name,
318+
type(parsed).__name__,
319+
)
320+
321+
return event_kwargs
322+
323+
251324
def _merge_top_level_dicts(
252325
base: dict[str, Any], new_values: dict[str, Any]
253326
) -> dict[str, Any]:
@@ -304,6 +377,7 @@ def convert_a2a_task_to_event(
304377

305378
try:
306379
event_actions = EventActions()
380+
event_metadata: dict[str, Any] = {}
307381
output_parts = []
308382
long_running_function_ids = set()
309383
if a2a_task.artifacts:
@@ -314,6 +388,7 @@ def convert_a2a_task_to_event(
314388
event_actions = _merge_event_actions(
315389
event_actions, _extract_event_actions(artifact.metadata)
316390
)
391+
event_metadata.update(_extract_event_metadata(artifact.metadata))
317392
output_parts, _ = _convert_a2a_parts_to_adk_parts(
318393
artifact_parts, part_converter
319394
)
@@ -325,6 +400,9 @@ def convert_a2a_task_to_event(
325400
event_actions,
326401
_extract_event_actions(a2a_task.status.message.metadata),
327402
)
403+
event_metadata.update(
404+
_extract_event_metadata(a2a_task.status.message.metadata)
405+
)
328406
parts, ids = _convert_a2a_parts_to_adk_parts(
329407
a2a_task.status.message.parts, part_converter
330408
)
@@ -337,6 +415,7 @@ def convert_a2a_task_to_event(
337415
author,
338416
event_actions,
339417
long_running_function_ids,
418+
event_metadata=event_metadata or None,
340419
)
341420

342421
except Exception as e:
@@ -380,6 +459,7 @@ def convert_a2a_message_to_event(
380459
invocation_context,
381460
author,
382461
_extract_event_actions(a2a_message.metadata),
462+
event_metadata=_extract_event_metadata(a2a_message.metadata),
383463
)
384464

385465
except Exception as e:
@@ -412,10 +492,15 @@ def convert_a2a_status_update_to_event(
412492
output_parts = []
413493
long_running_function_ids = set()
414494
event_actions = EventActions()
495+
event_metadata: Optional[dict[str, Any]] = None
415496
if a2a_status_update.status.message:
416497
event_actions = _extract_event_actions(
417498
a2a_status_update.status.message.metadata
418499
)
500+
event_metadata = (
501+
_extract_event_metadata(a2a_status_update.status.message.metadata)
502+
or None
503+
)
419504
parts, ids = _convert_a2a_parts_to_adk_parts(
420505
a2a_status_update.status.message.parts, part_converter
421506
)
@@ -428,6 +513,7 @@ def convert_a2a_status_update_to_event(
428513
author,
429514
event_actions,
430515
long_running_function_ids,
516+
event_metadata=event_metadata,
431517
)
432518
except Exception as e:
433519
logger.error("Failed to convert A2A status update to event: %s", e)
@@ -466,6 +552,10 @@ def convert_a2a_artifact_update_to_event(
466552
author,
467553
_extract_event_actions(a2a_artifact_update.artifact.metadata),
468554
partial=not a2a_artifact_update.last_chunk,
555+
event_metadata=_extract_event_metadata(
556+
a2a_artifact_update.artifact.metadata
557+
)
558+
or None,
469559
)
470560
except Exception as e:
471561
logger.error("Failed to convert A2A artifact update to event: %s", e)

tests/unittests/a2a/converters/test_to_adk.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,157 @@ def test_convert_a2a_artifact_update_to_event_none(self):
418418
"""Test convert_a2a_artifact_update_to_event with None."""
419419
with pytest.raises(ValueError, match="A2A artifact update cannot be None"):
420420
convert_a2a_artifact_update_to_event(None)
421+
422+
def test_convert_a2a_message_to_event_restores_custom_metadata(self):
423+
"""Test A2A message conversion restores custom_metadata."""
424+
a2a_part = Mock(spec=A2APart)
425+
a2a_part.root = Mock(spec=TextPart)
426+
a2a_part.root.metadata = {}
427+
message = Message(
428+
message_id="msg-1",
429+
role="user",
430+
parts=[a2a_part],
431+
metadata={
432+
_get_adk_metadata_key("custom_metadata"): {
433+
"my_key": "my_value",
434+
}
435+
},
436+
)
437+
438+
mock_genai_part = genai_types.Part.from_text(text="hello")
439+
mock_part_converter = Mock(return_value=[mock_genai_part])
440+
441+
event = convert_a2a_message_to_event(
442+
message,
443+
author="test-author",
444+
invocation_context=self.mock_context,
445+
part_converter=mock_part_converter,
446+
)
447+
448+
assert event is not None
449+
assert event.custom_metadata == {"my_key": "my_value"}
450+
451+
def test_convert_a2a_message_to_event_restores_error_code(self):
452+
"""Test A2A message conversion restores error_code."""
453+
a2a_part = Mock(spec=A2APart)
454+
a2a_part.root = Mock(spec=TextPart)
455+
a2a_part.root.metadata = {}
456+
message = Message(
457+
message_id="msg-1",
458+
role="user",
459+
parts=[a2a_part],
460+
metadata={
461+
_get_adk_metadata_key("error_code"): "RESOURCE_EXHAUSTED",
462+
},
463+
)
464+
465+
mock_genai_part = genai_types.Part.from_text(text="error")
466+
mock_part_converter = Mock(return_value=[mock_genai_part])
467+
468+
event = convert_a2a_message_to_event(
469+
message,
470+
author="test-author",
471+
invocation_context=self.mock_context,
472+
part_converter=mock_part_converter,
473+
)
474+
475+
assert event is not None
476+
assert event.error_code == "RESOURCE_EXHAUSTED"
477+
478+
def test_convert_a2a_message_to_event_metadata_only_returns_event(self):
479+
"""Test A2A message with only metadata (no parts/actions) returns event."""
480+
message = Message(
481+
message_id="msg-1",
482+
role="user",
483+
parts=[],
484+
metadata={
485+
_get_adk_metadata_key("custom_metadata"): {
486+
"reason": "metadata-only",
487+
}
488+
},
489+
)
490+
491+
event = convert_a2a_message_to_event(
492+
message,
493+
author="test-author",
494+
invocation_context=self.mock_context,
495+
part_converter=Mock(),
496+
)
497+
498+
assert event is not None
499+
assert event.custom_metadata == {"reason": "metadata-only"}
500+
assert event.content is None
501+
502+
def test_convert_a2a_status_update_restores_custom_metadata(self):
503+
"""Test A2A status update conversion restores custom_metadata."""
504+
a2a_part = Mock(spec=A2APart)
505+
a2a_part.root = Mock(spec=TextPart)
506+
a2a_part.root.metadata = {}
507+
update = TaskStatusUpdateEvent(
508+
task_id="task-1",
509+
status=TaskStatus(
510+
state=TaskState.input_required,
511+
timestamp="now",
512+
message=Message(
513+
message_id="m1",
514+
role="agent",
515+
parts=[a2a_part],
516+
metadata={
517+
_get_adk_metadata_key("custom_metadata"): {
518+
"trace_id": "abc-123",
519+
}
520+
},
521+
),
522+
),
523+
context_id="context-1",
524+
final=False,
525+
)
526+
527+
mock_genai_part = genai_types.Part.from_text(text="status text")
528+
mock_part_converter = Mock(return_value=[mock_genai_part])
529+
530+
event = convert_a2a_status_update_to_event(
531+
update,
532+
author="test-author",
533+
invocation_context=self.mock_context,
534+
part_converter=mock_part_converter,
535+
)
536+
537+
assert event is not None
538+
assert event.custom_metadata == {"trace_id": "abc-123"}
539+
540+
def test_convert_a2a_task_restores_custom_metadata_from_artifact(self):
541+
"""Test A2A task conversion restores custom_metadata from artifact."""
542+
task = Task(
543+
id="task-1",
544+
status=TaskStatus(
545+
state=TaskState.submitted, timestamp="2024-01-01T00:00:00Z"
546+
),
547+
context_id="context-1",
548+
artifacts=[
549+
Artifact(
550+
artifact_id="art-1",
551+
artifact_type="message",
552+
parts=[],
553+
metadata={
554+
_get_adk_metadata_key("custom_metadata"): {
555+
"task_key": "task_value",
556+
},
557+
_get_adk_metadata_key("actions"): {
558+
"stateDelta": {"saved_key": "saved-value"}
559+
},
560+
},
561+
)
562+
],
563+
)
564+
565+
event = convert_a2a_task_to_event(
566+
task,
567+
author="test-author",
568+
invocation_context=self.mock_context,
569+
part_converter=Mock(),
570+
)
571+
572+
assert event is not None
573+
assert event.custom_metadata == {"task_key": "task_value"}
574+
assert event.actions.state_delta == {"saved_key": "saved-value"}

0 commit comments

Comments
 (0)