Back to Blog
This post is Part 3 of 18 in the series: Agentic Commerce with ADK 2 — Building & Shipping CartPilot

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.

Share Editorial
Sessions & State: Making CartPilot Remember the Cart

Sessions & State: Making CartPilot Remember the Cart

📦 Companion Code: View the complete, runnable code on GitHub: part03 in 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.

python
# 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:

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:

  1. 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.
  2. Use output_key for anything you want auto-saved. If an agent's final response is the fact you want to persist (e.g. a computed shipping estimate), set output_key="shipping_estimate" on the agent instead of asking a tool to shove it into state — one less round-trip.
  3. Watch for tool results you don't need to keep verbatim. A search_products call 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_products returns 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.