A user tells your support agent on Monday that they are vegetarian and that deliveries go to their office. On Thursday they open a new chat and ask for a lunch order. If the agent asks about dietary needs again, the product feels broken, even though every individual conversation worked perfectly. Agents built with the Agent Development Kit for Java start each conversation as a fresh Session, and nothing carries over unless you design for it.

This article explains the two mechanisms that carry knowledge between sessions in ADK Java, prefixed state and the memory service: when each is written and read, what the framework leaves to you, how to make writes safe to repeat, and how to keep users apart. Framework behaviour was checked against the google/adk-java main branch in October 2026; where it depends on the service implementation, the article says so.

Advertisement

Sessions end; some knowledge should not

A Session is one conversation: an id, an app name, a user id, events and a map of state. The BaseSessionService creates, loads and deletes sessions, and the Runner appends events as the agent works. All of it is short-term memory that vanishes from view when a new session starts.

Lasting knowledge comes in two shapes. A few known facts with fixed keys, such as language or diet, want a key-value lookup. Open-ended history, such as what was promised or which fixes failed, wants search. ADK Java gives you one mechanism for each.

MechanismScopeRead byBest for
user: prefixed stateOne user, all their sessions in the appDirect key lookup in tools and callbacksStable preferences and profile facts
app: prefixed stateEvery user of the appDirect key lookupApp-wide settings, never personal data
Memory serviceOne user in one app, searchablesearchMemory, usually through LoadMemoryToolPast conversations and open-ended recall
Plain session stateOne sessionDirect key lookupWorking values for the current task

Two paths across the boundary

Session 1 (Monday)events + stateSession 2 (Thursday)new id, same userSession serviceuser: and app: mapsMemory serviceaddSessionToMemory / searchSession closeryour code, not the RunnerLlmAgentLoadMemoryTool, state toolsstate delta user:dietsession endsaddSessionToMemorymerged into new sessionsearchMemory(app, user, q)state lookupBoth stores are keyed by app name and user id. Neither is written across sessions unless the delta or the closer does it.
Two paths across the session boundary: prefixed state flows through the session service automatically on every event, while memory flows only when application code calls addSessionToMemory.

Prefixed state rides along with every event and needs no extra code. Memory crosses only when your code ingests a session, and returns only when the model or a callback asks for it.

Advertisement

Channel one: user and app state

The State class defines three key prefixes: app:, user: and temp:. When an event's state delta contains a key starting with user:, the in-memory session service strips the prefix and stores the value in a map keyed by app name and user id, separate from the session. A key starting with app: goes to a map keyed by app name only. When any session for that user is loaded or created, both maps are merged back into the session's state with their prefixes restored. The result is that a value written as user:diet in Monday's session is simply present in Thursday's.

That is the behaviour of InMemorySessionService, which loses everything when the process exits. Durable session services, whether a database-backed one or a managed cloud service, are separate implementations, and you should confirm that yours stores prefixed keys at user and app scope before relying on it; a quick test that writes a user: key in one session and reads it in another settles the question. The temp: prefix is a third convention; its exact handling also varies by implementation, so do not use it to carry anything across sessions.

Writes should go through events, not by mutating a loaded session object, because the session service only sees deltas that arrive with an appended event. In practice that means writing from a tool or callback through the context's state, which the framework records as a delta on the resulting event:

import com.google.adk.tools.Annotations.Schema;
import com.google.adk.tools.ToolContext;
import java.util.Map;

public final class ProfileTools {
  /** Called by the model when the user states a lasting preference. */
  public static Map<String, Object> rememberDiet(
      @Schema(name = "diet", description = "for example vegetarian, vegan, halal, none") String diet,
      ToolContext toolContext) {
    toolContext.state().put("user:diet", diet.toLowerCase());
    return Map.of("status", "saved", "diet", diet);
  }

  public static Map<String, Object> getProfile(ToolContext toolContext) {
    Object diet = toolContext.state().get("user:diet");
    return Map.of("diet", diet == null ? "unknown" : diet);
  }
}

Keep prefixed state small and typed. It is loaded into every session for that user, so a growing list of past orders belongs in the memory service or your own database, not in user: keys. And never put personal values under app:: that map is shared by every user of the app.

Channel two: the memory service

The memory service is the searchable path. Its contract, BaseMemoryService in com.google.adk.memory, is two methods wide. The write side, addSessionToMemory(Session session), ingests a session and completes with an RxJava Completable. The read side, searchMemory(String appName, String userId, String query), emits a Single<SearchMemoryResponse>; its memories() list holds MemoryEntry objects carrying content plus optional author and timestamp strings. The read side reaches the model through LoadMemoryTool, which lets the model call a memory function with a query; the tool fills in the current app and user through ToolContext.searchMemory. Core does not currently ship a tool that preloads memory into every turn, so recall is pull-based unless you add your own callback.

The point most people miss is on the write side: the Runner holds the memory service and passes it into each invocation, but it never calls addSessionToMemory itself. Nothing reaches long-term memory until your application decides a session is worth remembering and calls the method. That is a deliberate design: only the application knows when a conversation is over. The architecture of the service and its implementations is covered in the MemoryService architecture article; the rest of this one is about the boundary between sessions.

Choosing when to write memory

Because you own the write, you must choose its trigger. Three are common, and production systems often combine them.

  • Explicit end. The user closes the chat, the agent finishes a task, or a workflow reaches a terminal step. Precise, but users rarely close chats.
  • Idle timeout. A scheduled job ingests sessions whose last update is older than, say, thirty minutes. Session.lastUpdateTime() gives you the timestamp. This catches the abandoned tabs that are most conversations.
  • Periodic checkpoint. Every N turns, ingest the session so far. This bounds how much a crash can lose, at the cost of repeated ingestion of the same session.

Repeated ingestion is not a bug to avoid but a case to design for. The interface's own documentation says a session may be added multiple times during its lifetime. The reference InMemoryMemoryService handles this by storing each session's non-empty events under its session id, within a bucket for the app and user, and replacing them on every add, so a re-add updates rather than duplicates. A service you write yourself, over a vector store or a SQL table, must do the same: upsert keyed by session id, or by session id plus event id, and delete the old rows for that session in the same transaction. An append-only implementation turns every checkpoint into another copy of the conversation, and retrieval then returns the same memory three times.

public final class SessionCloser {
  private static final Logger log = LoggerFactory.getLogger(SessionCloser.class);
  private final BaseSessionService sessions;
  private final BaseMemoryService memory;

  public SessionCloser(BaseSessionService sessions, BaseMemoryService memory) {
    this.sessions = sessions;
    this.memory = memory;
  }

  /** Re-load the session so the latest events are ingested, then hand it to memory. */
  public Completable close(String appName, String userId, String sessionId) {
    return sessions.getSession(appName, userId, sessionId, Optional.empty())
        .flatMapCompletable(memory::addSessionToMemory)
        .retry(3)                                   // safe because ingestion is an upsert
        .doOnError(e -> log.warn("memory ingest failed for {}", sessionId, e));
  }
}

Wire both services into the runner so the agent can read what the closer writes. Runner.builder() takes the agent, app name, session service and memory service; every runAsync overload takes a RunConfig.

LlmAgent agent = LlmAgent.builder()
    .name("lunch_assistant")
    .model("gemini-2.5-flash")
    .instruction("Help the user order lunch. Call getProfile before asking about diet. "
        + "Use the memory tool when the user refers to earlier conversations.")
    .tools(new LoadMemoryTool(),
           FunctionTool.create(ProfileTools.class, "rememberDiet"),
           FunctionTool.create(ProfileTools.class, "getProfile"))
    .build();

Runner runner = Runner.builder()
    .agent(agent)
    .appName("lunch-app")
    .sessionService(sessionService)
    .memoryService(memoryService)
    .build();

What to remember: transcripts or facts

Ingesting raw sessions keeps everything, but transcripts are long, mostly irrelevant, and a later "actually, I eat fish now" leaves two contradictory transcripts. Many systems therefore extract on ingestion: a model call turns the session into a few short, dated facts ("2026-09-28: user switched to pescatarian"), which are stored and searched.

The two approaches are not exclusive. A sensible split is: stable profile facts go to user: state, where the latest value simply overwrites the old one; distilled, dated facts go to the memory service for search; and full transcripts, if you keep them at all, go to cold storage for audit rather than into retrieval. Whatever you store, include a timestamp, because recency is the cheapest tie-breaker when facts conflict. Ranking and query shaping are covered in long-term memory retrieval in ADK Java, and a pgvector implementation of the service is in semantic memory with vector stores.

Worked example: a Monday preference, recalled on Thursday

Follow the lunch example through both sessions. On Monday the user says "I'm vegetarian, and please always deliver to the Pune office reception." The model calls rememberDiet with vegetarian; the tool writes user:diet, the framework records it as a delta on the tool's event, and the session service moves it to the user map for lunch-app and user-17. The delivery instruction is not a tool call, so it stays in the transcript. Thirty-five minutes later the idle job finds the session, the closer reloads it and calls addSessionToMemory, and the memory service stores the turns, or the facts extracted from them, under the same app and user.

On Thursday the user opens a new session and asks for lunch. When the session is created, the user map is merged in, so user:diet is already in state. The model follows its instruction, calls getProfile, and learns the diet without asking. It then needs a delivery address, recognises that the user may have given one before, and calls the memory tool with a query such as "delivery address office". searchMemory runs against lunch-app and user-17 only, returns Monday's turn, and the agent proposes the Pune office reception and asks the user to confirm. One fact came back by key lookup and cost nothing; the other came back by search and cost one tool call. That split is exactly why the two mechanisms exist.

Now break it. Without the idle job, the address is lost and the diet survives. With in-memory services and a Tuesday restart, both are lost. These are the commonest reasons memory works in a demo and fails in production.

Scope, isolation and privacy

Every cross-session store is keyed by app name and user id, so the user id is a security boundary. It must come from your authenticated identity, never from a field the client can set, and never from anything the model produced. If two products share a memory backend, give them distinct app names, or one product's agent will recall the other's conversations. If your own memory service reaches a shared vector index, apply the app and user filter inside the query to the index, not after retrieving the top results, or a crowded index will quietly return other users' rows before your filter drops them.

Cross-session memory also turns a single sensitive message into a lasting record. Decide what must never be remembered, such as payment details, health information or credentials, and filter it at ingestion rather than at retrieval. Deletion has to reach every copy: the user: map, every stored session, the memory index and any extracted facts. The details are in implementing the right to be forgotten in ADK Java.

Failure modes and trade-offs

  • Memory never written. The team assumes the runner ingests sessions. It does not; without a closer or idle job, the memory service stays empty.
  • Duplicated memories. Checkpoints into an append-only store return the same turn several times and crowd out other results. Upsert by session id.
  • Stale or contradictory facts. Old transcripts outrank newer corrections. Store dated facts, prefer recent ones, and keep stable profile values in state where they overwrite.
  • Bloated user state. Lists and histories under user: keys are loaded into every session and inflate every prompt that reads them. Keep that state to a few scalar facts.
  • Wrong scope. Personal data under app:, or a client-supplied user id, leaks memory between users.
  • Volatile services in production. In-memory session and memory services are for tests; a restart erases every cross-session fact.
  • Prompt injection through memory. Text a user or a retrieved document planted in an earlier session comes back as trusted context later. Treat recalled memory as untrusted data in the prompt, not as instructions.

The trade-off: prefixed state is exact and free to read but suits only a handful of keys; the memory service handles open-ended recall but costs a search, may miss, and needs ingestion and deletion machinery you own. Use state for what the agent must always know and memory for what it might look up.

What to do next

  1. List the facts your agent should know in every session, and store them as user: keys written from tools.
  2. Write a test that sets a user: key in one session and reads it in a new one against your real session service.
  3. Add a session closer and an idle-timeout job that call addSessionToMemory; the runner will not.
  4. Make your memory service upsert by session id, and test that ingesting the same session twice yields one copy.
  5. Decide between raw transcripts and extracted, dated facts, and store a timestamp either way.
  6. Derive the user id from authentication only, and enforce the app and user filter inside every memory query.
  7. Define what must never be remembered, and verify that deletion reaches state, sessions and the memory index.
Key takeaway: ADK Java carries knowledge across sessions in two ways. Keys prefixed user: or app: are moved to user or app scope by the session service and merged into every new session, which suits a few stable facts. The memory service holds searchable history, but only after your code calls addSessionToMemory, because the Runner never does. Choose explicit, idle and checkpoint triggers, make ingestion an upsert, store dated facts, scope everything by authenticated app and user, and verify durable services before production.