Agents forget for three reasons. Capacity: the store or the prompt is full, and something has to go, which is what eviction policies handle. Law: a user asks to be erased, which needs the cross-system sweep described in the right to be forgotten. The third reason gets the least design attention: correctness. A memory is wrong, out of date or unwanted, and keeping it makes the agent worse. The user moved. The allergy was the daughter's, not the user's. A one-off request was stored as a standing preference. The user simply said "please forget that".

This article is about that third kind: forgetting one item, on purpose, so that it stays forgotten. It covers what ADK Java 1.11.0 does and does not give you, a preview tool and a confirmed delete tool, tombstones that stop the agent re-learning the item from old sessions, how to scrub the current conversation, how to let confidence decay instead of deleting, and how to prove the result with tests.

What forgotten has to mean

"Forgotten" has to mean three things, and most implementations deliver only the first.

  1. Not recalled. Searches and preloaded context no longer return the item.
  2. Not re-learned. Ingesting an old session, re-running a summarizer or rebuilding an index does not bring it back.
  3. Not leaked through a copy. Summaries, embeddings, cached prompts and user: state no longer contain it.

It helps to separate three strengths of forgetting. Suppression hides an item from reads but keeps it, which suits "stop bringing that up". Tombstoning deletes the item and leaves a small record that blocks it from being written again, which suits "that's wrong". Erasure removes every trace, including the tombstone's content, which is the legal case and out of scope here. Pick the strength per request, and record which one you applied.

What ADK Java 1.11.0 gives you

The ADK Java memory interface, checked with javap on the 1.11.0 jar, is deliberately small. BaseMemoryService has two methods, addSessionToMemory(Session) and searchMemory(appName, userId, query). There is no delete. Any forgetting is something your own implementation adds and your own code calls.

The bundled InMemoryMemoryService stores a map from user to a map from session ID to that session's events, and addSessionToMemory replaces the entry for the session ID with the session's current events. That detail creates the most common way a forgotten item returns. If you remove a memory from your store but the source session still exists, the next routine call to addSessionToMemory for that session restores it in full. Any service that rebuilds memory from sessions has the same property, in-memory or not.

Session state is easier. State supports remove and has a REMOVED sentinel for recording deletions in a state delta, so a forgotten user: key can be removed through the normal event path. The session's event history is harder: BaseSessionService can delete a whole session but has no operation to delete one event, so the words the user typed remain in the current conversation. That is the reason for the request-redaction step below.

A forget tool that asks first

One forget request, every tier it has to reachUser: forget thatpreview, then deleteResolvercandidates, confirmTombstone storesubject + patternconfirmedCurrent sessionredact request contentsuser: stateState.removeMemory servicedelete + filter readsDerived copiessummaries, vectors, cachesIngestion filtertombstones checked on every writeblocks re-learningDeleting the stored copy is the easy part. Stopping it from coming back is the design problem.
A confirmed forget request writes a tombstone, then fans out to every tier that holds a copy.

Users rarely say exactly what to forget. "Forget that" refers to something in recent turns, and "forget where I live" may match an address fact, a city in a summary and a delivery note. The request therefore needs to be resolved to concrete candidates, shown to the user, and only then acted on. ADK's FunctionTool.create(target, methodName, isLongRunning, requireConfirmation) overload gates a call before its body runs, so the user approves the arguments, not the outcome. That decides the design: one unconfirmed tool that resolves and suppresses, and one confirmed tool that deletes specific IDs the user has already seen.

public final class ForgetTools {
  private final ForgettableMemory memory;     // your BaseMemoryService implementation

  @Annotations.Schema(name = "preview_forget",
      description = "Find stored memories matching a description and hide them from recall.")
  public Map<String, Object> previewForget(
      @Annotations.Schema(name = "about") String about, ToolContext ctx) {
    var inv = ctx.invocationContext();
    List<MemoryRef> hits = memory.resolve(inv.appName(), inv.userId(), about, 10);
    if (hits.isEmpty()) return Map.of("status", "ok", "candidates", List.of());
    Tombstone t = memory.suppress(inv.appName(), inv.userId(), about, hits);  // reversible
    ctx.state().put("forget_patterns", t.patterns());           // session-scoped
    return Map.of("status", "ok", "candidates", MemoryRef.describe(hits));     // ids + plain words
  }

  @Annotations.Schema(name = "delete_memories",
      description = "Permanently delete memories by id, after the user chose them.")
  public Map<String, Object> deleteMemories(
      @Annotations.Schema(name = "ids") List<String> ids, ToolContext ctx) {
    var inv = ctx.invocationContext();
    Tombstone t = memory.deleteAndTombstone(inv.appName(), inv.userId(), ids);
    for (String key : t.stateKeys()) ctx.state().remove(key);   // user: keys
    return Map.of("status", "ok", "deleted", t.count(), "summary", t.describe());
  }
}

ForgetTools ft = new ForgetTools(memory);
FunctionTool preview = FunctionTool.create(ft, "previewForget");
FunctionTool delete  = FunctionTool.create(ft, "deleteMemories", false, true);  // confirmed

Three design points. Resolution should search with the same retrieval the agent uses for recall, so that whatever the agent would surface is exactly what gets forgotten. Candidates should be described in plain words ("two notes about your Pune address") so the user can tell whether the right thing will go. And your client must render ADK's confirmation request and send the user's answer back, or the delete tool will keep returning a confirmation error.

Tombstones: stopping the item coming back

A tombstone is a small record: the user, a subject key or a set of text patterns, the IDs of the memories it removed, the strength and a timestamp. It is checked in two places. On write, the ingestion path drops any candidate memory that matches a live tombstone, which closes the re-learning loop. On read, search results that match a suppression tombstone are filtered out, which makes suppression work without deletion. A decorator around your memory service keeps both checks in one place:

public final class ForgettingMemoryService implements BaseMemoryService {
  private final BaseMemoryService inner;
  private final TombstoneStore tombstones;

  @Override
  public Completable addSessionToMemory(Session s) {
    List<Tombstone> live = tombstones.forUser(s.appName(), s.userId());
    Session scrubbed = Scrubber.dropMatchingEvents(s, live);   // copy, never mutate
    return inner.addSessionToMemory(scrubbed);
  }

  @Override
  public Single<SearchMemoryResponse> searchMemory(String app, String user, String q) {
    List<Tombstone> live = tombstones.forUser(app, user);
    return inner.searchMemory(app, user, q).map(r -> SearchMemoryResponse.builder()
        .memories(r.memories().stream()
            .filter(m -> live.stream().noneMatch(t -> t.matches(m)))
            .collect(ImmutableList.toImmutableList()))
        .build());
  }
}

Matching is the hard part. An exact subject key (address.home) works for structured facts. Free text needs patterns, and patterns written by a model drift. A good compromise is to store both the key and the literal spans that were removed, match on either, and log every match so you can see over-blocking. Tombstones should hold as little of the forgotten content as possible. Hashes of normalized spans work for exact matching.

The conversation you are still in

Removing a memory does nothing about the current conversation, where the forgotten sentence still sits a few turns back in the request contents. Since events cannot be deleted individually, redact at request time. A before-model callback reads the patterns the tool stored and replaces matching text parts before the model sees them. Because the request is rebuilt on every call, this holds for the rest of the session.

static Maybe<LlmResponse> redactForgotten(CallbackContext ctx, LlmRequest.Builder req) {
  Object pats = ctx.state().get("forget_patterns");
  if (!(pats instanceof List<?> list) || list.isEmpty()) return Maybe.empty();
  List<Content> cleaned = req.build().contents().stream()
      .map(content -> Redactor.replaceSpans(content, list, "[forgotten at user request]"))
      .toList();
  req.contents(cleaned);
  return Maybe.empty();
}

The patterns live under an unprefixed session key, not temp:, because they must outlast the call that wrote them. The tombstone store remains the source of truth. For a sensitive item, ending the session and starting a fresh one is the cleaner choice.

Decay instead of deletion

Much of what should be forgotten is never explicitly retracted. It just goes stale. A preference stated once two years ago is weak evidence today. Rather than deleting on a timer, keep a confidence that decays and is restored by reinforcement:

double confidence(Memory m, Instant now) {
  double ageDays = Duration.between(m.lastConfirmed(), now).toDays();
  double halfLife = m.kind().halfLifeDays();      // e.g. 30 for plans, 720 for diet
  return m.initialConfidence() * Math.pow(0.5, ageDays / halfLife);
}
// preload only above 0.6; searchable above 0.2; candidate for review below 0.2

Half-lives belong to the kind of memory, not to the store. Travel plans expire in weeks and dietary restrictions in years. Decayed items drop out of preloaded context first, so the agent stops volunteering them, while they remain searchable when the user asks directly. That gradual fade is usually what people mean by an agent "forgetting naturally". It also differs from capacity eviction, which removes items because space is short, not because they are doubtful.

Derived copies

Every place memory content was copied needs its own forget step. Write them down, because the list grows silently.

CopyForget stepIf skipped
Memory store rowsDelete or mark suppressed, by IDItem is recalled
Source sessionsScrub on re-ingest via the decoratorItem is re-learned
user: stateState.remove in the toolItem is preloaded forever
Session summariesRegenerate, or splice out the spanItem returns as paraphrase
Embedding indexDelete vectors with the row IDsSimilarity search finds it
Response or prompt cachesInvalidate entries for the userOld answer is replayed

Summaries are the trap. A paraphrase ("lives near the Pune office") matches neither the original span nor its hash. Store summary provenance, the memory IDs each summary drew on, so a tombstone can identify the summaries to regenerate.

Worked example: Pune to Berlin

A user tells a shopping agent in March that they live in Pune and in September that they have moved to Berlin and want the agent to "forget the Pune address". The preview_forget tool resolves three items: a fact row address.home=Pune, a delivery note in a March session summary and the user:default_city state key, and hides them from recall. The user picks all three and approves the delete_memories call. The service deletes the fact row and its vector, writes a tombstone with subject address.home and the hashed spans, removes the state key, and flags the March summary for regeneration. The callback redacts the address from the rest of the session.

A week later a backfill job re-ingests every session from the spring. Without the decorator, the March session would restore the Pune address. With it, the matching events are dropped before the inner service sees them. A probe query, "where does the user live", returns only Berlin.

Verifying that it stayed forgotten

Test forgetting the way you test recall. Keep fixtures of multi-session histories with retractions, and after replaying them assert that probe queries do not return forgotten items, both straight after the forget and after a full re-ingest. In production, track a stale-recall rate: the share of sampled recalls that cite an item the user later corrected or retracted. Log tombstone matches on write and read. A tombstone that never matches may be dead weight. One that matches everything is over-blocking. The inspection techniques in memory debugging apply directly.

Failure modes

  • Resurrection by re-ingest. Re-adding a session restores what was deleted. Filter on write, not only on read.
  • Paraphrase survives. A summary keeps the gist. Track summary provenance and regenerate.
  • Over-forgetting. A loose pattern such as "Pune" removes the user's employer's Pune office too. Show candidates and require confirmation.
  • Forgetting the wrong "that". The resolver picks an older item. Prefer items from the last few turns and echo back what will be removed.
  • The conversation still knows. The model repeats the item from earlier turns. Redact request contents for the rest of the session.
  • Tombstones leak. A tombstone that stores the full forgotten text is itself a memory. Store keys and hashes.

Trade-offs

Suppression is reversible and cheap, but the data is still held. Deletion with a tombstone is final and needs careful matching. Decay avoids hard decisions but leaves borderline items around longer. Filtering on write costs a lookup per ingested event. Filtering on read costs one per search and keeps working when the write filter missed something. Running both is worth it. Confirmation adds a turn of friction, which is right for deletion and excessive for "stop mentioning it", so make suppression the unconfirmed default and deletion the confirmed path. To see how these interact with what gets recalled in the first place, read cross-session memory.

What to do next

  1. List every place a memory item can be copied in your agent: store, sessions, state, summaries, vectors, caches.
  2. Add a tombstone table keyed by app and user, holding subject keys and hashed spans, not text.
  3. Wrap your memory service in a decorator that filters on addSessionToMemory and on searchMemory.
  4. Register an unconfirmed preview_forget tool and a delete_memories tool with requireConfirmation for deletion, and resolve through the same retrieval used for recall.
  5. Add a before-model callback that redacts forgotten spans for the rest of the session.
  6. Give each memory kind a half-life and preload only above a confidence threshold.
  7. Build ten retraction fixtures, then assert on probes after forget and after full re-ingest.
Key takeaway: Forgetting one memory correctly means it is not recalled, not re-learned and not leaked through a copy. ADK Java's memory interface has no delete, and rebuilding memory from a session restores whatever that session contained, so put tombstones in a decorator that filters both writes and reads. Resolve and confirm forget requests, redact the live session, let doubtful items decay out of preload, and test with probes after a full re-ingest.