aiml
SearchMy learning
Menu

Lesson 5 of 17Module 1

Persist session state and long-term memory

Persist Google ADK 2.0 sessions in SQLite with DatabaseSessionService, keep user state across restarts and recall earlier conversations with load_memory.

Prerequisites

Python, a Gemini API key, and the preceding lessons in Agents and tools.

By the end, you can

  • Persist sessions in a database with DatabaseSessionService, and choose between session, user:, app: and temp: state.
  • Write state from a tool and from output_key, and read it in the instruction with a template.
  • Add finished sessions to a memory service and recall them with load_memory, knowing that InMemoryMemoryService is not persisted: the SQLite sessions survive a restart and the memory starts empty.
Runtime
Python 3.12+ and a Gemini API key
Dependencies
google-adk[db] 2.12.0, litellm and python-dotenv

Lab code, tests and fixtures (ZIP, 26 KB)Quiz check your understanding

Progress is saved in this browser.

When you need this. A developer tells the assistant which docuctl version they use, then returns in a new conversation. The version should still be available. Asking “Which release did you mention earlier?” needs something different: a search over past conversations. We will implement both and check what survives a process restart.

DatabaseSessionService saves conversation events and state in SQLite. A separate InMemoryMemoryService makes selected sessions searchable while the process is running. In this lab, SQLite is persistent; the memory service is not.

data flowreturnfailurerun 1Runner + Agentset_docuctl_versionoutput_keymemoryin memory,lost at exitaddsearchrun 2, new servicesRunner + Agentinstruction readsuser:docuctl_versionmemorystarts emptyaddsearchKeyErrorno ?sessions.dbDatabaseSessionService · SQLiteevents + state_deltauser: stateeventsStaleSessionErrorappend from an old copydata flowreturnfailurerun 1Runner + Agentaddsearchmemorylost at exiteventssessions.dbSQLiteStaleSessionErrorstateeventsrun 2, new servicesRunner + Agentversion: 1.xaddsearchmemorystarts emptyKeyErrorno ?
SQLite retains the event history and user-scoped state. Searchable memory is a separate service and starts empty when this process restarts.

Install the database extra

Download the lab using Download code and open adk-sessions-memory-lesson:

Prepare the session and memory projectTerminal · bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
cp .env.example .env

Set your private Gemini key in .env. The assistant loads it before choosing its model. The database extra supplies SQLAlchemy and aiosqlite; SQLite needs no server. session_client.py creates learner-sessions.db for your exercise, separate from the supplied saved examples.

The assistant reads this page to distinguish docuctl versions:

docs/rollback.mdSource · markdownDownload
# Roll back a release

`docuctl rollback` moves an environment back to an earlier release. It does not rebuild anything; it points traffic at the release you name.

    docuctl rollback --env production --to r41

Without `--to`, docuctl rolls back to the previous release. A rollback is itself recorded as a new release, so `docuctl releases` shows it.

Database migrations are not rolled back. If the release you leave ran a migration, write a new migration that reverses it.

docuctl 1.x used `docuctl release revert <release>`. That command was removed in docuctl 2.0.

Save state from a tool, and read it in the instruction

The tool records the developer’s version. The instruction reads that value when ADK builds each request:

assistant.py — state writes, instruction lookup and memory toolSource · pythonDownload
def set_docuctl_version(version: Literal["1.x", "2.x"], tool_context: ToolContext) -> dict:
    """Remember which docuctl version the developer uses, for this and later conversations."""
    tool_context.state["user:docuctl_version"] = version  # user: state is shared by the user's sessions
    return {"saved": version}


INSTRUCTION = """You answer questions from developers about docuctl, the team's deploy CLI.
The developer's docuctl version: {user:docuctl_version?}
When the developer says which version they use, call set_docuctl_version first.
If the version is empty and the answer depends on it, ask for it. Read the relevant page with
read_page and answer for the developer's version. When the developer refers to an earlier
conversation, call load_memory. Keep the answer under 80 words."""

root_agent = Agent(
    name="docs_assistant",
    model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
    instruction=INSTRUCTION,
    tools=[read_page, set_docuctl_version, load_memory],
    output_key="last_answer",  # the final reply is also saved to session state
    generate_content_config=types.GenerateContentConfig(
        temperature=0.2, max_output_tokens=1024, thinking_config=types.ThinkingConfig(thinking_level="low")),
)

tool_context.state["user:docuctl_version"] writes through the tool event’s state_delta. The session service applies that delta when it appends the event. output_key="last_answer" similarly saves the final answer to session state. The instruction’s {user:docuctl_version?} reads the version; the ? makes an absent value optional.

Key Scope
last_answer One session.
user:docuctl_version This user’s sessions within the application.
app:setting All users of the application.
temp:value The current invocation; not persisted in stored state.

The user: prefix fits a developer preference. The session service stores shared user state separately and merges it into the loaded session’s state map.

Writing to a fetched session. Mutating an object returned by get_session() changes that object, not the database. Write through ToolContext.state, output_key or an appended event with EventActions(state_delta=...).

A required placeholder with no value. Omitting the ? makes a missing version raise KeyError before the model call. Use an optional placeholder or initialize the key before running the agent.

Run two processes against one database

The Runner binds the two stores separately:

assistant.py — persistent sessions and a separate memory serviceSource · pythonDownload
def make_runner(memory: InMemoryMemoryService, agent: Agent = root_agent, db_url: str = DB_URL) -> Runner:
    """A runner whose sessions live in a database; memory is a separate, searchable store."""
    return Runner(app_name=APP, agent=agent, session_service=DatabaseSessionService(db_url=db_url),
                  memory_service=memory)

The client creates or resumes a session, runs the question and prints its ID, event transcript and saved state:

session_client.py — run questions and close the storesSource · pythonDownload
async def run_questions(options, agent=assistant.root_agent):
    memory = InMemoryMemoryService()
    url = "sqlite+aiosqlite:///" + str(options.db.resolve())
    runner = assistant.make_runner(memory, agent=agent, db_url=url)
    results = []
    try:
        if options.session:
            session = await runner.session_service.get_session(app_name=assistant.APP, user_id=options.user, session_id=options.session)
            if session is None:
                raise ValueError("No session with that ID for this user; omit --session to create one.")
        else:
            session = await runner.session_service.create_session(app_name=assistant.APP, user_id=options.user)
        for index, text in enumerate([options.question] + ([options.followup] if options.followup else [])):
            if index:
                session = await runner.session_service.create_session(app_name=assistant.APP, user_id=options.user)
            events = await assistant.ask(runner, options.user, session.id, text)
            saved = await runner.session_service.get_session(app_name=assistant.APP, user_id=options.user, session_id=session.id)
            result = {"session_id": session.id, "user_id": options.user, "transcript": assistant.show(events), "state": dict(saved.state)}
            results.append(result)
            print(json.dumps(result, ensure_ascii=False))
            if index == 0 and options.remember:
                await memory.add_session_to_memory(saved)
    finally:
        try:
            await runner.close()
        finally:
            await runner.session_service.close()
    return results

Start with a version-specific question:

Save a preference in Dana's user stateTerminal · bash
python session_client.py "I am on docuctl 1.x. How do I roll back production to r41?" --user dana

Check the set_docuctl_version tool call and user:docuctl_version in the printed state. Its presence depends on the tool running; an answer that merely repeats “1.x” is not a persistence check. Compare any command in the answer with the bundled guide.

Now run a second process with the same user and database:

Read the same user's state after restarting the clientTerminal · bash
python session_client.py "Which docuctl version am I using?" --user dana

The database can supply the saved version to the new session. To resume the original conversation, add --session YOUR_SESSION_ID; omit that option to create a new one. Repeating the question with --user lee checks a different user.

The supplied saved sessions show that distinction:

Recorded example. The archived “Persist session state and long-term memory” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.

Saved user-state comparison with replacement servicesRecorded output · text
dana: user:shipctl_version=1.x
lee: user:shipctl_version=(unset)

Two writers on one session. Appending through a stale session copy raises StaleSessionError. Reload the session and serialize turns for the same session ID instead of retrying a write blindly.

Separate session state from long-term memory

load_memory searches the Runner’s memory service for the current user. A finished session does not enter that service just because SQLite saved it. Add it explicitly with add_session_to_memory().

Try both conversations in one process:

Make the first conversation searchable before the follow-upTerminal · bash
python session_client.py "I am on docuctl 1.x. How do I roll back production to r41?" --user dana --remember --followup "Which release did you tell me to revert production to earlier?"

--remember adds the completed first session before creating the follow-up session. Inspect the load_memory call and result when the assistant refers back to it. Repeat without --remember to compare the empty memory service.

Memory lost on restart. Exiting this process discards InMemoryMemoryService. The SQLite user state survives, but the remembered conversation does not. Persistent searchable memory requires a different memory service or an explicit reload strategy.

The local --user flag is a test identity, not authentication. A real application must derive it from the authenticated caller. Adding a session to memory also makes its content searchable, so decide what to retain and how to delete it.

Test the store without a model

Run the SQLite and memory checksTerminal · bash
python -m unittest -v test_assistant.py output_cap_check.py

These tests replay saved model turns against temporary SQLite databases and real memory services. They check state scope, event-based writes, stale sessions, missing placeholders and memory loss without a provider request.

The client restarts with the same SQLite file and a new InMemoryMemoryService. What can it recover?

Choose one answer, then check your reasoning.

Read the explanation

The session service and memory service have independent lifecycles and persistence guarantees.

Next, guard model and tool calls so secrets are removed before session storage or model requests.

References

Written by Arjun Prabhulal.

Module 1

Agents and tools