Lab 03 — Sessions, Scoped State & the Callback Chain
Phase 22 · Lab 03 · Phase README · Warmup
The problem
Two Google ADK subsystems make an agent stateful and controllable. This lab builds faithful miniatures of both.
1) Sessions & scoped state. A SessionService (InMemory / Database / VertexAI in real ADK)
owns Session objects, and every session has a state dict — the agent's working memory across
turns. The trick is state scope prefixes: a key's prefix decides how widely it is shared:
| Prefix | Scope | Shared across |
|---|---|---|
| (none) | session | just this one conversation (private) |
user: | user | all of that user's sessions (preferences) |
app: | app | all sessions of the app, every user (global config) |
temp: | temporary | this turn only; never persisted |
session.state is a single dict-like view that routes each read/write to the right backing
store by prefix. This is Phase 04 context-engineering/memory, productized: ADK separates "what
this chat knows" from "what this user always wants" from "what the whole app shares" without you
managing three dictionaries by hand.
2) The callback chain. Around each step of an agent, ADK fires paired callbacks you register:
before_agent/after_agent, before_model/after_model, before_tool/after_tool. They are
the interception layer — logging, metrics, caching, and guardrails (Phase 10). The
load-bearing rule: a before_* callback that returns a value SHORT-CIRCUITS the wrapped step.
Return a response from before_model and the (paid) model call is skipped — a cache or a policy
block. Return a result from before_tool and the tool never runs — a guardrail denying a
dangerous action. An after_* callback can transform the step's output (redact PII, clamp a
value). Returning None means "proceed / don't modify".
Everything is injected and deterministic: the model is a pure policy, session ids are a counter (no uuid), no clock. That is precisely how you unit-test ADK guardrails — assert a blocked tool never executed and that a cache hit skipped the model.
What you build
| Object | What it does | The lesson |
|---|---|---|
State | one MutableMapping view routing by key prefix over 4 backing dicts | scoped memory without three dictionaries |
InMemorySessionService | create/get/list/delete sessions; owns the shared user/app stores | how scope sharing is implemented |
CallbackAgent | an agent exposing all six lifecycle callbacks | the interception surface |
Runner | fires the callback chain in order, honoring every short-circuit; records trace | guardrails/caches wired into the lifecycle |
File map
| File | Role |
|---|---|
lab.py | your implementation (fill the # TODOs) |
solution.py | reference implementation + worked example (python solution.py) |
test_lab.py | 25 tests: scope routing/sharing, service lifecycle, callback order, short-circuits, transforms, determinism |
requirements.txt | pytest only |
Run it
pytest test_lab.py -v # your lab.py (red until you implement)
LAB_MODULE=solution pytest test_lab.py -v # the reference (green)
python solution.py # worked example
Success criteria
-
Unprefixed state is private to a session;
user:keys are shared across that user's sessions (but not other users);app:keys are shared across all users of the app (but not other apps). Seeding initial state routes by prefix too. -
The six callbacks fire in order:
before_agent → before_model → after_model → after_agent(withbefore_tool/after_toolwrapping any tool call). -
A
before_model_callbackreturning a response skips the model (cache hit); abefore_tool_callbackreturning a value blocks the tool (the dangerous function never runs); abefore_agent_callbackreturning a value skips the whole agent body. -
after_*callbacks transform output (uppercase a response, redact a tool result, wrap a final answer). ReturningNoneproceeds unchanged. -
All 25 tests pass under both
labandsolution.
How this maps to the real stack
State,Session, andInMemorySessionServiceare real ADK classes ingoogle.adk.sessions. The scope prefixes (user:,app:,temp:) are the real ADK convention, andsession.statereally is a view that routes writes to the right tier; in production you swapInMemorySessionServiceforDatabaseSessionServiceorVertexAiSessionServiceand the same scoping semantics persist to a real store. Long-term recall across sessions is a separateMemoryService(cross-reference Phase 04).- The six callbacks are the real ADK hook names (
before_agent_callback,after_agent_callback,before_model_callback,after_model_callback,before_tool_callback,after_tool_callback), registered on an agent, and the short-circuit contract is exactly real: return aContentfrombefore_model_callbackand ADK skips the model; return adictfrombefore_tool_callbackand ADK skips the tool. This is the canonical place to implement guardrails (Phase 10), response caching (Phase 14), and PII redaction. Real callbacks receive aCallbackContext/ToolContextwith.stateand more; ours carries.stateandagent_name. - Writing the final response to
session.state[output_key]is the same behavior you saw in Lab 01 and relied on in Lab 02 — the through-line of the phase.
Limits of the miniature. No persistence (in-memory only), temp: is per-session rather than
strictly per-invocation, no async, and the model/tool loop is simplified (one tool round per
model turn, capped by max_steps). The two mechanisms that matter — prefix-scoped state sharing
and the before_* short-circuit — are faithful.
Extensions (your own machine)
- Add a
DatabaseSessionServicebacked bysqlite3: sameStateview, persisted stores, and proveuser:/app:state survives a process restart. - Implement a real response cache in
before_model_callbackkeyed by a hash of the request, and measure the model-call reduction on a repeated-query workload (Phase 14). - Build an injection guardrail as a
before_tool_callbackthat scans arguments for an exfil URL or a path outside an allow-list and blocks the call (Phase 10), with a test that the tool never executed. - Add a
MemoryServicestub and anafter_agent_callbackthat writes the turn's summary to long-term memory for later retrieval.
Interview / resume signal
"Built Google ADK's stateful/controllable core: an in-memory
SessionServicewith prefix-scoped state (user:/app:/temp:sharing tiers) and the six-callback lifecycle chain — implementing thebefore_*short-circuit as a cache (skip the paid model) and a guardrail (block a dangerous tool before it runs), withafter_*transforms for PII redaction — all deterministic and unit-tested."