Skip to content

[Bug] custom_metadata not persisted by DatabaseSessionService, breaks RemoteA2aAgent context_id reuse across requests #6055

Description

@Gautam0610

Bug Description

custom_metadata on Event objects is silently dropped when persisted
by DatabaseSessionService. This breaks RemoteA2aAgent which relies
on custom_metadata to reuse the same A2A context_id across requests
from the same orchestrator session.

ADK Version

2.2.0

Root Cause

In schemas/v1.py, StorageEvent.from_event serializes events using:

event_data=event.model_dump(exclude_none=True, mode="json")

custom_metadata is not a declared Pydantic field on Event — it is
set dynamically at runtime by RemoteA2aAgent:

# remote_a2a_agent.py line 567-571
event.custom_metadata = event.custom_metadata or {}
event.custom_metadata[A2A_METADATA_PREFIX + "task_id"] = task.id
event.custom_metadata[A2A_METADATA_PREFIX + "context_id"] = task.context_id

Because it is not a Pydantic field, model_dump() never includes it.
to_event() never restores it. Every DB round-trip silently loses it.

Impact

RemoteA2aAgent._construct_message_parts_from_session reads
context_id from previous events' custom_metadata:

# remote_a2a_agent.py line 456-458
if event.custom_metadata:
    metadata = event.custom_metadata
    context_id = metadata.get(A2A_METADATA_PREFIX + "context_id")

When custom_metadata is lost, context_id is always None, so
every request to a sub-agent creates a brand new A2A context instead
of reusing the existing one. This means:

  1. Sub-agents lose all session continuity across requests
  2. Token usage tracking breaks — each request starts accumulating
    from zero in a new context
  3. Multi-turn conversations with sub-agents don't work correctly
  4. Works fine with InMemorySessionService (no DB round-trip)
    but breaks with DatabaseSessionService

Reproduction

  1. Set up an orchestrator using DatabaseSessionService with a
    RemoteA2aAgent sub-agent
  2. Send two sequential requests in the same session
  3. Observe logs — every request creates a new context_id:

Request 1
Task not found. Creating new task (context_id: aaa-111)

Request 2 — should reuse aaa-111, creates new one instead
Task not found. Creating new task (context_id: bbb-222)

With InMemorySessionService, request 2 correctly reuses aaa-111.

Expected Behavior

custom_metadata should survive DB round-trips so RemoteA2aAgent
can reuse context_id across requests in the same session.

Workaround

Monkey-patch StorageEvent.from_event and to_event to manually
preserve custom_metadata in event_data:

@classmethod
def _patched_from_event(cls, session, event):
    storage_event = _original_from_event(cls, session, event)
    if hasattr(event, 'custom_metadata') and event.custom_metadata:
        if storage_event.event_data is None:
            storage_event.event_data = {}
        storage_event.event_data['custom_metadata'] = event.custom_metadata
    return storage_event

def _patched_to_event(self):
    event = _original_to_event(self)
    if self.event_data and 'custom_metadata' in self.event_data:
        event.custom_metadata = self.event_data['custom_metadata']
    return event

Suggested Fix

Either:

Option A — Add custom_metadata as a proper Pydantic field on Event:

# events/event.py
custom_metadata: Optional[dict[str, Any]] = None

Option B — Explicitly include it in from_event serialization:

# schemas/v1.py StorageEvent.from_event
event_data = event.model_dump(exclude_none=True, mode="json")
if hasattr(event, 'custom_metadata') and event.custom_metadata:
    event_data['custom_metadata'] = event.custom_metadata

return StorageEvent(
    ...
    event_data=event_data,
)

And restore it in to_event:

def to_event(self) -> Event:
    event = Event.model_validate({...})
    if self.event_data and 'custom_metadata' in self.event_data:
        event.custom_metadata = self.event_data['custom_metadata']
    return event

Option A is cleaner as it makes custom_metadata a first-class field
that serializes automatically.

Environment

  • OS: Ubuntu / Linux
  • Python: 3.12
  • google-adk: 2.2.0
  • sqlalchemy: (run pip show sqlalchemy | grep Version)
  • Database: SQLite (sqlite+aiosqlite)

Minimal Reproduction Code

# orchestrator setup
from google.adk.sessions import DatabaseSessionService
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
from google.adk.tools import agent_tool

session_service = DatabaseSessionService("sqlite+aiosqlite:///test.db")

remote_agent = RemoteA2aAgent(
    name="sub_agent",
    description="test",
    agent_card="http://localhost:8001/.well-known/agent.json",
)

# Send request 1 — sub_agent gets context_id = aaa-111
# Send request 2 in SAME session — sub_agent gets NEW context_id = bbb-222
# Expected: context_id = aaa-111 reused
# Actual: new context_id every request

Additional Context

This regression was introduced in ADK v2. In ADK v1 the A2A session
handling preserved context continuity across requests automatically.
The bug only manifests with DatabaseSessionService —
InMemorySessionService is unaffected because events are never
serialized. The custom_metadata field is also absent from the Event class
definition entirely (events/event.py) — it is only set as a dynamic
attribute by RemoteA2aAgent at runtime, which is why model_dump()
cannot capture it. The fix requires changes in both events/event.py
(declare the field) and schemas/v1.py (serialize/deserialize it).

Activity

  1. added
    services[Component] This issue is related to runtime services, e.g. sessions, memory, artifacts, etc
    on Jun 10, 2026
  2. added theissue type on Jun 10, 2026
  3. Gautam0610 commented on Jun 10, 2026

    @Gautam0610
    Author

    Please update on this issue

  4. boyangsvl commented on Jun 11, 2026

    @boyangsvl
    Collaborator

    Hi! I noticed that custom_metadata is already defined in the base class LlmResponse (which Event inherits from). Is there a specific reason we need to redefine it here in Event?

  5. Gautam0610 commented on Jun 11, 2026

    @Gautam0610
    Author

    custom_metadata is inherited from LlmResponse, but it is not preserved through the DatabaseSessionService round-trip. StorageEvent does not include it when serializing to or deserializing from the database, so the value is lost on every read-back. The field redefinition here is intentional to make it explicit and ensure it is included in model_dump() output — but if you prefer, the fix can be moved to the StorageEvent serialization path in schemas/v1.py instead.

    Hi! I noticed that custom_metadata is already defined in the base class LlmResponse (which Event inherits from). Is there a specific reason we need to redefine it here in Event?

  6. boyangsvl commented on Jun 11, 2026

    @boyangsvl
    Collaborator

    Are you referring to this in schemas/v1.py:

      @classmethod
      def from_event(cls, session: Session, event: Event) -> StorageEvent:
        """Creates a StorageEvent from an Event."""
        return StorageEvent(
            id=event.id,
            invocation_id=event.invocation_id,
            session_id=session.id,
            app_name=session.app_name,
            user_id=session.user_id,
            timestamp=datetime.fromtimestamp(event.timestamp),
            event_data=event.model_dump(exclude_none=True, mode="json"),
        )
    

    It dumps everything in event to event data. So I don't understand why custom_metadata is not there. Could you clarify?

  7. Gautam0610 commented on Jun 11, 2026

    @Gautam0610
    Author

    Are you referring to this in schemas/v1.py:

      @classmethod
      def from_event(cls, session: Session, event: Event) -> StorageEvent:
        """Creates a StorageEvent from an Event."""
        return StorageEvent(
            id=event.id,
            invocation_id=event.invocation_id,
            session_id=session.id,
            app_name=session.app_name,
            user_id=session.user_id,
            timestamp=datetime.fromtimestamp(event.timestamp),
            event_data=event.model_dump(exclude_none=True, mode="json"),
        )
    

    It dumps everything in event to event data. So I don't understand why custom_metadata is not there. Could you clarify?

    You're right that event.model_dump() should include it if the field is properly defined. After digging further, the issue is that custom_metadata is defined on LlmResponse with exclude=True in the model_config or is excluded via the field definition, so it is silently dropped by model_dump() even though it appears on the object. You can verify this with:
    python

    from google.adk.models import LlmResponse
    import inspect
    f = LlmResponse.model_fields.get('custom_metadata')
    print(f)

    If the field has exclude=True or is not in model_fields at all (i.e. it is a plain Python attribute rather than a Pydantic field), model_dump() will not include it regardless of exclude_none=True. That is the root cause — the fix needs to ensure custom_metadata is a proper Pydantic field that model_dump() actually serializes.

  8. boyangsvl commented on Jun 11, 2026

    @boyangsvl
    Collaborator

    custom_medata is defined without exclude=True

      custom_metadata: Optional[dict[str, Any]] = None
    
  9. Gautam0610 commented on Jun 11, 2026

    @Gautam0610
    Author

    custom_medata is defined without exclude=True

      custom_metadata: Optional[dict[str, Any]] = None
    

    Thanks for confirming. Since custom_metadata is a proper Pydantic field without exclude=True, model_dump() should include it when set. The issue is actually in the restore path — from_event uses exclude_none=True, so if custom_metadata is None at the time of the first write it gets dropped from event_data. More importantly, does the code that reconstructs Event from a stored StorageEvent row explicitly pass custom_metadata back? If the restore only does Event(**event_data) and event_data never included custom_metadata due to exclude_none=True, it will always come back as None. Can you point me to the restore path so I can confirm?

  10. boyangsvl commented on Jun 11, 2026

    @boyangsvl
    Collaborator

    exclude_none=True is the expected behavior. custom_metadata is not always present so one should check None before using. Closing as working as intended.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

services[Component] This issue is related to runtime services, e.g. sessions, memory, artifacts, etc

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions