Skip to content

InMemorySessionService retains appended Event references, allowing later mutations to change saved history and state聽#7701

Open

Description

@BichengWang

馃敶 Required Information

Describe the Bug:
With the default deep-copy configuration, InMemorySessionService.append_event() retains the caller's mutable Event and nested state-delta values. Mutating that event after the append returns changes the stored conversation history and session state without another append. A subsequent get_session() returns those changed values.

Steps to Reproduce:

  1. Check out main at 48d18a6aea65ed399bf036f29e383219ec167b3b (ADK 2.12.0) with runtime dependencies installed.
  2. Save the code below as repro.py and run PYTHONPATH=src python repro.py.
  3. Compare the in-memory result with the SQLite control. No model, credentials, or network are used.

Expected Behavior:
In the default deep-copy mode, a completed append should preserve the saved event and state. Changing the caller's retained event should leave the saved answer as original answer and the saved cart as {'items': ['coffee']}.

Observed Behavior:
The in-memory service reloads changed after append and {'items': ['coffee', 'tea']}. The SQLite control reloads the original answer and cart.

Environment Details:

  • ADK Library Version: source 2.12.0 at the commit above.
  • Desktop OS: macOS 27.0.1, arm64.
  • Python Version: 3.13.16; dependencies installed from cache offline.

Model Information:

  • LiteLLM: No.
  • Model: N/A; session-service calls only.

馃煛 Optional Information

Regression: Unknown; no earlier release was tested.

Logs:

InMemorySessionService: text='changed after append', cart={'items': ['coffee', 'tea']}
SqliteSessionService: text='original answer', cart={'items': ['coffee']}

Screenshots / Video: N/A.

Additional Context:
This is the append boundary, separate from #7676's initial create_session(state=...) snapshot and #7105's copies of scoped state returned to callers. It also reproduces on #7676's head 110262ad33f2c82b6eaa47faca9d6931e0566782. #7454 concerns duplicate delivery to stale callers; this reproduction appends one event once. No fix is included.

Minimal Reproduction Code:

import asyncio

from google.adk.events import Event
from google.adk.events.event_actions import EventActions
from google.adk.features import FeatureName, override_feature_enabled
from google.adk.sessions import InMemorySessionService
from google.adk.sessions.sqlite_session_service import SqliteSessionService
from google.genai import types


async def check(service):
    try:
        session = await service.create_session(app_name="app", user_id="user")
        event = Event(
            author="assistant",
            content=types.Content(
                role="model", parts=[types.Part(text="original answer")]
            ),
            actions=EventActions(state_delta={"cart": {"items": ["coffee"]}}),
        )
        await service.append_event(session, event)
        event.content.parts[0].text = "changed after append"
        event.actions.state_delta["cart"]["items"].append("tea")
        stored = await service.get_session(
            app_name="app", user_id="user", session_id=session.id
        )
        text = stored.events[0].content.parts[0].text
        print(f"{type(service).__name__}: text={text!r}, cart={stored.state['cart']}")
    finally:
        if isinstance(service, SqliteSessionService):
            await service.close()


async def main():
    override_feature_enabled(FeatureName.IN_MEMORY_SESSION_SERVICE_LIGHT_COPY, False)
    await check(InMemorySessionService())
    await check(SqliteSessionService(":memory:"))


asyncio.run(main())

How often has this issue occurred?: Always with the reproduction above.

Activity

  1. widechaos commented on Oct 23, 2026

    @widechaos

    May I take this on if nobody is already working on it? I reproduced the append-boundary mutation and have a local candidate that snapshots the event after temporary state is trimmed, then uses that snapshot for internal history and persistent scoped state. The caller鈥檚 event/return value and light-copy reference sharing stay unchanged.

    An independent source-revert check gives four failures on main and 22 passes with the candidate, covering nested session/user/app state, SQLite controls, partial events, temporary non-copyable values, and the canonical-session append path. The related session/Runner suite passes 1041 tests (one skipped, three xfailed).

    One compatibility point needs confirmation: persistent payloads that cannot be deep-copied would fail in default mode at append rather than later at get/list. Light-copy still accepts them. Is that boundary consistent with the intended default snapshot contract? I have not submitted a PR; full supported-Python tox validation remains outstanding.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions