Skip to content

Commit 3df5a65

Browse files
GWealecopybara-github
authored andcommitted
docs: add Session unit guide
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 962458183
1 parent 461205c commit 3df5a65

2 files changed

Lines changed: 218 additions & 0 deletions

File tree

docs/guides/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ This directory contains specific developer guides for the ADK Python implementat
2020
* [ReflectAndRetryModelPlugin](plugins/reflect_retry_model_plugin/index.md) - Self-healing, concurrent-safe error recovery for model failures.
2121
* [ReflectAndRetryToolPlugin](plugins/reflect_retry_tool_plugin/index.md) - Self-healing, concurrent-safe error recovery for tool failures.
2222

23+
### Sessions
24+
* [Session and BaseSessionService](sessions/session/index.md) - The session lifecycle, state scoping, and choosing a session service.
25+
2326
### Tools
2427
* [to_mcp_server](tools/mcp_tool/agent_to_mcp/index.md) - Expose an ADK agent as an MCP server so any MCP host can drive it as a single tool (the MCP counterpart of to_a2a).
2528

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# Session and BaseSessionService
2+
3+
`Session` is the conversation record — its id, its owner, its state, and its
4+
ordered event history. `BaseSessionService` is the storage interface that
5+
creates, reads, lists, and deletes those records and appends events to them.
6+
7+
## Introduction
8+
9+
An agent run is stateless on its own: the model sees only what you give it. A
10+
`Session` is what carries a conversation across turns, holding the event history
11+
that becomes the model's context and a `state` dict that agents and tools read
12+
and write.
13+
14+
`Session` is a plain Pydantic model and never talks to storage itself.
15+
Everything that persists a session goes through a `BaseSessionService`, which
16+
declares four abstract methods — `create_session`, `get_session`,
17+
`list_sessions`, `delete_session` — plus a concrete `append_event` that every
18+
backend inherits. That split is why the same agent code runs unchanged against
19+
an in-process dict during development and a shared database in production: you
20+
swap the service, not the agent. `Runner` takes a `session_service` as a
21+
required argument and drives `get_session` and `append_event` for you, so most
22+
applications call the service directly only to create, list, and delete
23+
sessions.
24+
25+
## Get started
26+
27+
`InMemorySessionService` needs no configuration. This example creates a
28+
session, appends two events, and reads the result back.
29+
30+
```python
31+
import asyncio
32+
33+
from google.adk.events import Event
34+
from google.adk.sessions import InMemorySessionService
35+
36+
APP_NAME = "hello_world"
37+
USER_ID = "user-123"
38+
39+
40+
async def main() -> None:
41+
session_service = InMemorySessionService()
42+
43+
# 1. Create. Omit session_id to have one generated for you.
44+
session = await session_service.create_session(
45+
app_name=APP_NAME,
46+
user_id=USER_ID,
47+
state={"locale": "en-US"},
48+
)
49+
50+
# 2. Append events. Each one lands in session.events, and any state the
51+
# event carries is merged into session.state.
52+
await session_service.append_event(
53+
session, Event(author="user", message="What is the weather?")
54+
)
55+
await session_service.append_event(
56+
session,
57+
Event(
58+
author="weather_agent",
59+
message="It is sunny.",
60+
state={"last_city": "Zurich"},
61+
),
62+
)
63+
64+
# 3. Read it back. get_session returns None when nothing is stored.
65+
loaded = await session_service.get_session(
66+
app_name=APP_NAME, user_id=USER_ID, session_id=session.id
67+
)
68+
assert loaded is not None
69+
print(len(loaded.events), loaded.state)
70+
71+
72+
if __name__ == "__main__":
73+
asyncio.run(main())
74+
```
75+
76+
This prints `2 {'locale': 'en-US', 'last_city': 'Zurich'}`.
77+
78+
Every method is keyword-only except `append_event`, which takes the session and
79+
the event positionally. A session is identified by the triple
80+
`(app_name, user_id, session_id)`, not by `session_id` alone, so all three are
81+
required on every read.
82+
83+
## How it works
84+
85+
### The lifecycle
86+
87+
`create_session` generates a UUID when you do not pass `session_id`, and raises
88+
`AlreadyExistsError` (from `google.adk.errors.already_exists_error`) when you
89+
pass one that is already taken. `get_session` returns `None` for a missing
90+
session rather than raising. `list_sessions` returns a `ListSessionsResponse`
91+
ordered by `last_update_time`, oldest first, with the event history omitted.
92+
93+
`append_event` is where the two copies of a session meet. The base
94+
implementation applies the event's `actions.state_delta` to the in-memory
95+
`Session` you hold and appends to `session.events`; each backend overrides it to
96+
write the event to storage as well. Partial events (`event.partial` is true) are
97+
returned untouched and never stored, which is how streaming chunks stay out of
98+
the history.
99+
100+
### State scoping
101+
102+
Keys in `state` are scoped by prefix, and the prefixes are constants on `State`:
103+
104+
| Prefix | Constant | Scope |
105+
| --- | --- | --- |
106+
| none | | This session only. |
107+
| `app:` | `State.APP_PREFIX` | Every session of the app. |
108+
| `user:` | `State.USER_PREFIX` | Every session of this user within the app. |
109+
| `temp:` | `State.TEMP_PREFIX` | The current invocation only; never persisted. |
110+
111+
Write prefixed keys like any other key, in `create_session(state=...)` or in an
112+
event's state delta. The service routes them to the right storage scope and
113+
merges them back into `session.state` on read, prefix included. `temp:` keys are
114+
the exception: they are applied to the in-memory session so later agents in the
115+
same invocation can read them, then stripped from the event before it is
116+
written.
117+
118+
`get_user_state(app_name=..., user_id=...)` reads user-scoped state without a
119+
session id, returning raw keys with the `user:` prefix removed — useful for
120+
bootstrapping context before `create_session`. It is not abstract, and the
121+
default implementation raises `NotImplementedError`, so a custom backend that
122+
does not override it will fail this call.
123+
124+
### Trimming what you load
125+
126+
Pass a `GetSessionConfig` to bound the history you read back. It lives in
127+
`google.adk.sessions.base_session_service`, not in the package root:
128+
129+
```python
130+
from google.adk.sessions.base_session_service import GetSessionConfig
131+
132+
# The 20 most recent events. Use num_recent_events=0 for metadata and state
133+
# only, or after_timestamp=<unix seconds> to cut the history by time instead.
134+
recent = await session_service.get_session(
135+
app_name=APP_NAME,
136+
user_id=USER_ID,
137+
session_id=session_id,
138+
config=GetSessionConfig(num_recent_events=20),
139+
)
140+
```
141+
142+
The service applies these filters, so on a database backend they reduce what is
143+
read, not just what you see.
144+
145+
## Choosing a session service
146+
147+
| Service | Import | Use it when |
148+
| --- | --- | --- |
149+
| `InMemorySessionService` | `google.adk.sessions` | Developing and testing. State lives in process dicts and the class documents itself as unsuitable for multi-threaded production. |
150+
| `DatabaseSessionService` | `google.adk.sessions` | You need durability, or several processes sharing one conversation. Backed by a SQLAlchemy async engine; requires the `db` extra. |
151+
| `VertexAiSessionService` | `google.adk.sessions` | You are deploying on Vertex AI Agent Engine and want its managed session store. Requires the `gcp` extra. |
152+
| `SqliteSessionService` | `google.adk.sessions.sqlite_session_service` | You want a local SQLite file and no server. This is what the ADK CLI uses; note it is not re-exported from the package root. |
153+
154+
`DatabaseSessionService` takes either a URL or an engine you already own, and
155+
exactly one of the two:
156+
157+
```python
158+
from google.adk.sessions import DatabaseSessionService
159+
160+
async with DatabaseSessionService("sqlite+aiosqlite:///./sessions.db") as svc:
161+
await svc.prepare_tables() # optional; otherwise done on first use
162+
session = await svc.create_session(app_name=APP_NAME, user_id=USER_ID)
163+
```
164+
165+
Use an async driver in the URL — `sqlite+aiosqlite`, `postgresql+asyncpg`, and
166+
so on. Passing `db_engine=<AsyncEngine>` instead reuses your application's
167+
engine, and the service will not dispose of one it did not create. As an async
168+
context manager it closes the engine it owns on exit; call `close()` yourself
169+
otherwise.
170+
171+
`VertexAiSessionService` differs in one respect worth knowing before you switch
172+
to it: `app_name` is not a free-form string there. It must be the reasoning
173+
engine id or the full `projects/.../locations/.../reasoningEngines/N` resource
174+
name, unless you pass `agent_engine_id` to the constructor.
175+
176+
## Advanced applications
177+
178+
### Wiring a service into a Runner
179+
180+
* **Problem solved**: one place decides where every conversation is stored.
181+
* **Implementation**: pass the service to `Runner(session_service=...)` and
182+
create the session before the first run. `Runner` defaults
183+
`auto_create_session` to `False`, so an unknown `session_id` raises
184+
`SessionNotFoundError` instead of silently starting a new conversation.
185+
186+
### Writing your own backend
187+
188+
* **Problem solved**: your sessions belong in a store ADK does not ship.
189+
* **Implementation**: subclass `BaseSessionService` and implement the four
190+
abstract methods. Override `append_event` to persist the event and call
191+
`await super().append_event(session, event)` so the in-memory session stays
192+
in step. Override `get_user_state` if your store can answer it, and `flush`
193+
if you buffer writes — the base `flush` is a no-op that `Runner` calls when
194+
it closes.
195+
196+
### Detecting a stale session
197+
198+
* **Problem solved**: two workers hold the same `Session` object and both
199+
append, so one would silently overwrite the other's history.
200+
* **Implementation**: nothing to write. `DatabaseSessionService` tracks a
201+
storage revision per session and raises `ValueError` from `append_event`
202+
when the in-memory copy has fallen behind. Recover by calling `get_session`
203+
again and replaying the append against the fresh session.
204+
205+
## Limitations
206+
207+
* **`InMemorySessionService` is not for production**: nothing survives a
208+
restart, nothing is shared between workers, and it does not lock.
209+
* **`append_event` fails differently per backend**: appending to a session
210+
that storage does not know about raises `SessionNotFoundError` on
211+
`DatabaseSessionService`, while `InMemorySessionService` logs a warning and
212+
returns the event unstored.
213+
* **`list_sessions` returns partial sessions**: the event history is dropped,
214+
and how much of `state` is populated depends on the backend. Load what you
215+
need with `get_session`.

0 commit comments

Comments
 (0)