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.
Install the database extra
Download the lab using Download code and open adk-sessions-memory-lesson:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
cp .env.example .envSet 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:
# 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:
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:
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:
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:
python session_client.py "I am on docuctl 1.x. How do I roll back production to r41?" --user danaCheck 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:
python session_client.py "Which docuctl version am I using?" --user danaThe 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.
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:
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
python -m unittest -v test_assistant.py output_cap_check.pyThese 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.
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
- ADK session state: state scopes and supported writes.
- ADK session services: conversation storage.
- ADK memory: adding sessions and searching memory.