Sessions & State: Making CartPilot Remember the Cart
Master multi-turn conversational memory and session persistence in ADK 2. Isolate state with InMemorySessionService and minimize context window token inflation.

Sessions & State: Making CartPilot Remember the Cart
📦 Companion Code: View the complete, runnable code on GitHub:
part03in the cartpilot-adk2-series repository.
CartPilot can search products. It has no memory. Ask it to add something to a cart, then ask what's in the cart, and it has no idea — every turn starts from nothing. This part fixes that, and along the way we look at the token cost of multi-turn memory, which is easy to get expensively wrong.
Sessions, Runners, and where state lives
ADK's Runner executes an agent against a Session, which a SessionService persists. InMemorySessionService is fine for local dev; production swaps in a persistent backend (database-backed session service) without touching agent code.
Critically: state doesn't have to mean conversation history. ADK's tool_context.state is a plain dict scoped to the session (or user, or app — see the scoping note below) that tools can read and write directly. That's where CartPilot's cart lives — not as a paragraph the model has to re-parse ("earlier you added a jacket, then some socks, then removed the jacket..."), but as structured data the model reads fresh each turn.
# cartpilot_cart/agent.py
from google.adk.agents import Agent
from google.adk.tools import ToolContext
from .catalog import find_products, get_product
CART_KEY = "cart" # list[dict] of {sku, qty} stored in session state
def add_to_cart(sku: str, quantity: int, tool_context: ToolContext) -> dict:
"""Adds a product to the shopper's cart.
Args:
sku: The product SKU to add.
quantity: How many units to add. Must be a positive integer.
"""
product = get_product(sku)
if product is None:
return {"status": "not_found", "sku": sku}
if quantity <= 0:
return {"status": "invalid_quantity", "message": "Quantity must be positive."}
if product["stock"] < quantity:
return {"status": "insufficient_stock", "available": product["stock"]}
cart = tool_context.state.get(CART_KEY, [])
for line in cart:
if line["sku"] == sku:
line["quantity"] += quantity
break
else:
cart.append({"sku": sku, "quantity": quantity})
tool_context.state[CART_KEY] = cart
return {"status": "success", "cart": cart}
def view_cart(tool_context: ToolContext) -> dict:
"""Returns the current contents of the shopper's cart with line totals."""
cart = tool_context.state.get(CART_KEY, [])
lines, subtotal = [], 0.0
for entry in cart:
product = get_product(entry["sku"])
if not product:
continue
line_total = product["price"] * entry["quantity"]
subtotal += line_total
lines.append({"sku": entry["sku"], "name": product["name"],
"quantity": entry["quantity"], "line_total": round(line_total, 2)})
return {"status": "success", "lines": lines, "subtotal": round(subtotal, 2)}
def remove_from_cart(sku: str, tool_context: ToolContext) -> dict:
"""Removes a product entirely from the shopper's cart.
Args:
sku: The product SKU to remove.
"""
cart = tool_context.state.get(CART_KEY, [])
new_cart = [line for line in cart if line["sku"] != sku]
if len(new_cart) == len(cart):
return {"status": "not_in_cart", "sku": sku}
tool_context.state[CART_KEY] = new_cart
return {"status": "success", "cart": new_cart}
root_agent = Agent(
model="gemini-3.5-flash",
name="cartpilot_agent",
description="Helps shoppers search products and manage their cart.",
instruction="""
You are the CartPilot shopping assistant.
- Use `search_products` to help shoppers find items.
- Use `add_to_cart`, `view_cart`, and `remove_from_cart` to manage the cart.
- Always confirm what changed after a cart action (e.g. "Added 2x Trail
Runner Jacket — your subtotal is now $179.98.").
- Never guess at cart contents — call `view_cart` if you're not sure.
- Keep responses short (2-4 sentences).
""",
tools=[search_products, add_to_cart, view_cart, remove_from_cart],
)Seeing it work without the browser
adk web wires up a Runner and SessionService for you. To see that wiring explicitly — useful once you're debugging state bugs — here's the same thing in plain Python:
# main.py
import asyncio
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
from cartpilot_cart.agent import root_agent
APP_NAME = "cartpilot"
USER_ID = "demo-user"
async def main():
session_service = InMemorySessionService()
session = await session_service.create_session(app_name=APP_NAME, user_id=USER_ID)
runner = Runner(agent=root_agent, app_name=APP_NAME, session_service=session_service)
for turn in ["Do you have any water bottles?", "Add one to my cart.", "What's in my cart so far?"]:
print(f"\n> {turn}")
message = types.Content(role="user", parts=[types.Part(text=turn)])
async for event in runner.run_async(user_id=USER_ID, session_id=session.id, new_message=message):
if event.is_final_response() and event.content and event.content.parts:
print(event.content.parts[0].text)
final_session = await session_service.get_session(app_name=APP_NAME, user_id=USER_ID, session_id=session.id)
print("\n--- raw session state ---")
print(final_session.state) # {'cart': [{'sku': 'sku-002', 'quantity': 1}]}
if __name__ == "__main__":
asyncio.run(main())Run with python main.py. The final print shows exactly what persisted — a two-key dict, not a transcript.
The token growth problem
Session state (the cart dict) is cheap — it stays small and structured no matter how long the shopper browses. Conversation history is the part that grows unbounded, and it's what actually gets re-sent as input tokens on every single turn. A 40-turn support conversation doesn't cost 40 turns' worth of tokens — turn 40 alone re-reads roughly the entire history that preceded it, so total cost across a session grows roughly quadratically with turn count if you do nothing about it.
ADK doesn't leave you to solve this from scratch: it already filters irrelevant events out of what gets sent to the model, summarizes older conversational turns, and lazy-loads artifacts rather than inlining them — this is on by default, not something you opt into. Three habits still matter on top of that:
- Put durable facts in state, not history. The cart is the clearest example — a shopper's 15th message shouldn't require the model to re-derive "what's in the cart" by reading messages 3, 7, and 12. It reads
state["cart"]directly. - Use
output_keyfor anything you want auto-saved. If an agent's final response is the fact you want to persist (e.g. a computed shipping estimate), setoutput_key="shipping_estimate"on the agent instead of asking a tool to shove it into state — one less round-trip. - Watch for tool results you don't need to keep verbatim. A
search_productscall that returns 20 results doesn't need all 20 sitting in history for the rest of the conversation — trim what a tool returns to what's actually useful downstream (we already do this:search_productsreturns matches, not the whole catalog).
We'll make this concrete with real numbers in Part 10, once we've wired up token/cost tracking via callbacks.
Scoping note
tool_context.state supports prefixes: unprefixed keys (cart) are session-scoped, user: prefixed keys persist across a user's sessions (e.g. user:loyalty_tier), and app: prefixed keys are global to the app. We're not using cross-session state yet, but it's there when CartPilot needs to remember a shopper's preferences between visits.
What's next
view_cart currently returns a hand-shaped dict. That's fine for a tool feeding the model, but the moment CartPilot needs to emit an order confirmation or receipt — something downstream systems will parse — "the model wrote sensible-looking JSON" isn't good enough. Part 4 covers guaranteed structured output.