The Archive — Memory at Agent Valley

1. Introduction

Vesper, the archivist, at the desk on the ground floor

Week one asked who says what runs. Week two asked who decides what runs next. Week three asked what a run leaves behind. This week the conversation ends, and the question is what survives it — and it turns out that what kind of thing it is decides where it goes.

The Archive is where the valley comes when something has gone wrong. A tower that writes everything down, and Vesper, the archivist, at the desk on the ground floor. Your lantern goes out at night. You go and ask.

She is good company. She is also, a month later, delighted to meet you.

A record is not a memory.

That sentence is the whole lab. You will climb it in four rungs, and each rung is a floor of the tower:

rung

the floor

what it remembers

what keeps it

1

the desk, and floor one

this visit

session.state · SqliteSessionService

2

floor two, your drawer

this visitor

the user: prefix

3

floor three, the tower

what was said

Vertex AI Memory Bank

4

floor four, the season

the whole valley

BigQuery

Every rung up is because something fell out of the one below.

The Archive, cut open — five floors, and a small crew keeping every one of them

You already know the archivist's five ways of forgetting, because every one of them has happened to you on a support call:

what she says

what went wrong

~~1~~

~~Could you give me that reference again?~~

~~forgot within one conversation~~ — week three fixed this

~~2~~

~~I've transferred you. Please start from the beginning.~~

~~the history died with the process~~ — week three fixed this too

3

Welcome! How can I help you today?

to someone with an open case from last month

4

Could you describe the damage?

she looked at the lantern last time

5

We've never seen this before.

thirty-one people have asked this season

Line three is this week. It fails three different ways — a new visit (chapter 2), a sentence with no line on the slip (chapter 3), and an archive that lives only inside the process (chapter 4) — and each way is a chapter. Line four is left to you: look() in archive/agent.py is where she reads a mark off a photo. Line five is chapter 5, and it is optional.

You do not need weeks one to three. An agent is a model with an instruction and a few tools. Everything else, this week explains itself.

You will write three lines, and every one of them is a memory concept:

the line

what it is

tool_context.state[CASE] = case

state is written by code, never by the conversation

CASE = "user:case"

a prefix is a lifetime

found = await ctx.search_memory(query)

the recall policy — filed is not remembered

One more line is already there for you to read — the write policy, await ctx.add_events_to_memory(...) — and chapter 3 is where you read what it does before you write the line that uses it.

The other two rungs are not edits at all: you build a tower (an Agent Engine that hosts Memory Bank) and the same code connects to it on its own, and you load a warehouse (BigQuery), lay a graph over it, and hand the same agent two governed queries — one by meaning, one by connection.

Every chapter runs the same loop, and it is the loop you will use on your own agents: read the code → write the line → watch it in the tower. Nothing runs broken first, nothing is restarted, and nothing is Ctrl+C'd: every edit is picked up on the next message, and connecting the tower is one line in a file the Archive re-reads on every request. The tower is a drawing that redraws: it reads the same files you edit, and it has three buttons — 🌙 ends the day, 🗓 starts the next visit, 🔥 forgets you. Everything else you do in the code and the terminal.

Two places, and they never change jobs: tab 1 is the terminal, tab 2 is the Archive. The workbench (adk web) appears exactly once, in chapter 1, for one look at raw session state, and then you are done with it. Every 👉 below says which. 👉💻 is tab 1, 👉🖥 is tab 2, 👉🔬 is the workbench. 👉📖 is open a file and read — nothing to change — and 👉✏️ is the one kind of step that edits. Whenever a step tells you to say something to Vesper, the exact message is in the block right under it — copy it as it is. And a section whose heading ends in — read only has nothing to type, paste or click: the code in it is already in the repo and already running, printed for you to read.

The sentence over Vesper's door is the one at the top. The sentence this week ends on is the closing line of the whole season's fourth week:

Facts and preferences belong in different stores.

2. 🧰 Setup

  • What — point at a project or paste a key, install, and prove the toolchain is green.
  • Why — nothing gets booted here. Every server starts right before the step that needs it, so when a screen opens you know which command owns it.
  • How — one script that makes the project, one that installs and points you at Vertex AI, and one preflight that reads your code and tells you where you are.

Everything in this lab runs in Cloud Shell — Python, uv and gcloud are already installed there, and Gemini is reached through Vertex AI using your own credentials. There is no API key anywhere in this lab, and nothing to install on your laptop.

There is one thing this lab cannot hand you: a Google Cloud project with billing on it. Step 1 gets you an account that can pay for it; step 4 turns that account into a project — or picks up the Agent Valley project you already made in week one, week two or week three. Do them in that order — every step after 4 reads the project that step 4 settles on.

1 · Get an account with billing (~1 min)

🌐 Do this before anything else. Sign in with a Google account that has billing enabled. Any such account works. If yours does not, start a free trial; it gives you a billing account, which is all step 4 needs.

2 · Open Cloud Shell (~1 min)

🌐 Click Activate Cloud Shell at the top of the Google Cloud console — it is the terminal-shaped icon in the header.

Activate Cloud Shell from the Google Cloud console header

🌐 Click Open Editor (the open-folder-with-a-pencil icon). This brings up the Cloud Shell Editor with a file explorer on the left — that is where you work whenever a step is marked 👉✏️.

Open the Cloud Shell Editor

🌐 If you are prompted to authorize at any point today, click Authorize to continue.

🌐 Open a terminal inside the editor — that is tab 1, and every 👉💻 step runs there unless it says otherwise. Tab 2 is the Archive, and you open it in chapter 1, right before the step that needs it. If the terminal is not already along the bottom of the screen, open it with View → Terminal:

Open a new terminal in the Cloud Shell Editor

🌐 Check which account you are signed in as — it should be the one from step 1:

gcloud auth list

3 · Get the lab (~1 min)

💻 Clone the repo — this is the Archive repo, not week one's, week two's or week three's:

cd ~
git clone https://github.com/cuppibla/agent-valley-archive
cd agent-valley-archive

4 · Create your project — or reuse an earlier week's (~2 min)

💻 One script finds your billing account, creates a project on it, and points Cloud Shell at that project. If you did week one, week two or week three, it finds that Agent Valley project first and offers to reuse it — say yes and there is no second billable project to think about:

cd ~/agent-valley-archive
./setup_project.sh

👀 Expect a ✓ for the billing account it picked, a ✓ for the project it created or reused, and an ending like:

cd ~/agent-valley-archive
Project ready: agent-valley-4271
  Next:  ./setup_codelab.sh

agent-valley-4271 is your project ID — yours will have different digits. Four things happen in there:

billing

takes the newest [YYYY-MM-DD] GDP Credit: account if you have one, and any other open billing account otherwise

project

looks for an Agent Valley project you already own — week one's, week two's or week three's — and offers to reuse it; otherwise creates agent-valley-XXXX and links that billing account to it

selects

writes the ID to ~/project_id.txt and runs gcloud config set project, so nothing after this asks you to type it

waits

polls until the project is actually allowed to serve, for up to two minutes

🌐 The same project is now in the console — its ID sits in the Project info card on the Dashboard:

Your project ID on the console Dashboard

5 · Install and point at Vertex AI (~3 min)

💻 Install uv, export the three variables that send ADK to Vertex AI, and run setup:

cd ~/agent-valley-archive
curl -LsSf https://astral.sh/uv/install.sh | sh
source ~/.local/bin/env
export GOOGLE_GENAI_USE_VERTEXAI=TRUE
export GOOGLE_CLOUD_PROJECT=$(gcloud config get project)
export GOOGLE_CLOUD_LOCATION=global
./setup_codelab.sh

$(gcloud config get project) has something to return because step 4 set it. That is the whole reason step 4 comes first.

setup_codelab.sh is idempotent — safe to re-run any number of times. It does five things, in order:

api

reads the project step 4 selected and enables aiplatform.googleapis.com (Vertex AI, and the Agent Engine that hosts Memory Bank) and bigquery.googleapis.com (chapter 5's warehouse) on it

uv env

uv venv creates .venv, or reuses the one already there, then uv sync installs exactly what uv.lock pins — google-adk, google-genai and everything beneath them

site

runs npm ci in site/ — the Archive itself is a Next.js app — so bash valley.sh in chapter 1 has nothing left to download; skipped when the packages are already there

.env

writes the variables the lab reads — GOOGLE_GENAI_USE_VERTEXAI, GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION, MEMORY_BANK_LOCATION and BQ_DATASET, and no API key anywhere

model check

makes one real call to Gemini, so a bad project fails here at setup time, not later inside a chapter

6 · Prove it is green (~1 min)

💻 Run the preflight — every tick is a real probe, not a guess:

cd ~/agent-valley-archive
uv run python scripts/preflight.py

👀 The preflight asks the model one question to prove your credentials work, checks that the Archive's packages are in place, then reads your code and prints this week's progress bar:

cd ~/agent-valley-archive
  desk      ▢ the slip      · write_down writes nothing         chapter 1 · EDIT ONE
  floor 1   ✓ the file      · SqliteSessionService              chapter 1, read only
  floor 2   ▢ the drawer    · CASE = "case"                     chapter 2 · EDIT TWO
  floor 3   ✓ what was said · closing time files the day        chapter 3, read only
  floor 3   ▢ the asking    · recall never looks                chapter 3 · EDIT THREE
  floor 3   ▢ the tower     · InMemoryMemoryService             chapter 4 · built, not edited
  floor 3   ✓ the rules     · USER_PREFERENCES, LANTERN_CONTEXT chapter 4, read only

Three are already ticked. Those are the ones you only read. Three are lines you write; one is a tower you build. Run the preflight again at any point in the lab and it will tell you where you are — it reads your files, not a checklist.

3. Session, State, SqliteSessionService

  • What — write one line; start the workbench once and watch the slip fill in; then the same in the tower; then read why it survives the night.
  • Why — the first thing to see about memory is that nothing is remembered on its own. Somebody writes it.
  • How — one line in agent.py, then change nothing else.

EDIT ONE

👉📖 Open archive/agent.py and find write_down. Read to the 👉:

    case = dict(tool_context.state.get(CASE, {}))
    if case.get(field):
        return {"already_written": {field: case[field]}}
    case[field] = value
    # 👉 EDIT ONE — chapter 1. She heard you. Nothing was written. Delete the
    #    "# " in front of the next line, then save — the slip is session state,
    #    and state is written by code, never by the conversation:
    # tool_context.state[CASE] = case
    return {"written": {field: value}}

The function builds the slip in a local dict and then returns it — and a local dict dies with the call. Run this as it stands and Vesper hears you perfectly: four write_down calls fire, each one answers "written", and the session state stays empty, because nothing ever reached it. She heard you. Nothing was written.

👉✏️ Still in archive/agent.py, inside write_down: delete the # in front of one line — the # and the space after it. Before:

    case[field] = value
    # 👉 EDIT ONE — chapter 1. She heard you. Nothing was written. Delete the
    #    "# " in front of the next line, then save — the slip is session state,
    #    and state is written by code, never by the conversation:
    # tool_context.state[CASE] = case
    return {"written": {field: value}}

After:

    case[field] = value
    # 👉 EDIT ONE — chapter 1. She heard you. Nothing was written. Delete the
    #    "# " in front of the next line, then save — the slip is session state,
    #    and state is written by code, never by the conversation:
    tool_context.state[CASE] = case
    return {"written": {field: value}}

Save.

The slip fills in

👉💻 Start the workbench. This is the one time in the lab you open it — it is the only window that shows session state as the raw JSON it is:

cd ~/agent-valley-archive
uv run adk web . --allow_origins="*"

👉🔬 Open the workbench with Web Preview → Preview on port 8000 (the Web Preview button is the eye-over-a-window icon at the top right of Cloud Shell). Pick archive, and before you type anything, click State in the left panel.

👀 Empty. Nobody has said anything yet.

👉🔬 Now say this, all in one message:

Hi! My lantern keeps going out at night. My case number is k7f2, and there's a little q7 stamped on the base.

👀 Four write_down chips fire — click one and read the JSON: "written": {"no": "k7f2"}. Now click State again.

Four things, four fields, one message

case   {"no": "k7f2", "item": "lantern", "mark": "q7",
        "symptom": "keeps going out at night"}

👀 Empty, then full, inside one message. That is what she remembers with, and it did not appear on its own. You wrote the line that writes it. That is the whole mechanism: a plain function that writes into tool_context.state, and an instruction that tells her to use it whenever the visitor says something that fits a line on the slip.

👉🔬 Ask her:

What's my case number?

👀 k7f2. She reads it off the slip. Without EDIT ONE she would have asked you for it — line one of the support list, inside one conversation.

The two guards are the slip being a paper form: four short lines, each written once. They matter more than they look. Without them the model discovers it can pour the whole conversation into symptom, and chapter 3 — the chapter whose entire point is the slip has no line for that — quietly stops being true. A store's shape is enforced by the code that writes it, or it is not enforced.

Now go to the tower

👉💻 You are done with the workbench — leave it running or close its tab; the rest of the lab lives in the Archive. Open a second terminal tab (tab 2 — the Archive):

cd ~/agent-valley-archive
bash valley.sh

👉🖥 Open the Archive with Web Preview → Change port, type 3440, and click Change and Preview. 3440 is not in Web Preview's quick list — 8080 is its default — so you have to set it by hand this once. Press ▶ Start, then The Archive. You are at the door. Pick whoever has come to ask — a name and a species — and go in.

The tower, cut open

👀 One building. Vesper is at the desk on the ground floor, and that is where you talk. Above her: floor one, floor two, floor three, and a dark attic. The four rungs are the four floors. Your familiar is sitting on the ground floor, because that is as far as you have climbed. The column beside the drawing spells out what each floor holds right now — the same four stores as the table in the introduction, in the same order. Nothing on the right is a button.

👉🖥 Paste the same four things into the box under the conversation — this exact message — and press Enter:

Hi! My lantern keeps going out at night. My case number is k7f2, and there's a little q7 stamped on the base.

Four lines on the desk, and the same four beside it

👀 Watch the desk. The slip writes itself, one line at a time — on the drawing, and in full under the slip beside it. Same tool, same state: the tower is just drawing what write_down wrote, and it will keep doing that for the rest of the lab.

She remembers overnight — read only

What you wrote, and what it is

the line

what it is

where it lives

tool_context.state[CASE] = case

session state — written by code, read by the instruction

the desk · session.state

SqliteSessionService(DB) — you only read it

the session store — a visit in a file, not in a process

floor one · archive.db

This rung reaches one visit, and it survives closing time. It does not survive the next visit.

4. User State

  • What — come back a month later; the slip is gone. Five characters, and it follows you.
  • Why — a session is one visit. The next visit is a new session, and it has never seen the last one.
  • How — five characters in state.py.

A month later. Pleased to meet you.

Press it here

👉🖥 Press 🗓 a month later. That is a new session for the same visitor — the way start a new conversation was in week three.

Welcome to the Archive. How can I help you today?

👉🖥 Look at the tower. Floor one has another book on it — this visit. The drawer on floor two is empty. The slip on the desk is blank.

Another book on floor one; the drawer empty; the slip blank

👀 Why: the slip is written into session state, and a session is this visit. Change visits, change sessions. Nothing carried over because nothing was told to. The last visit is still in the file — that is floor one's first book — but the slip was written under a key that lives for one visit.

EDIT TWO

👉✏️ Open archive/state.py. Find the 👉. Before:

# 👉 EDIT TWO — chapter 2. Five characters. A key with no prefix lives for one
#    visit; `user:` follows the visitor into every visit after this.
CASE = "case"

After:

CASE = "user:case"

Save. No restart — the Archive re-imports your code on every message.

👉🖥 Paste the same four things again — this exact message:

Hi! My lantern keeps going out at night. My case number is k7f2, and there's a little q7 stamped on the base.

👉🖥 Then press 🗓 a month later again — the same button as before.

The drawer, with a name on it

👀 The drawer on floor two has your name on it, and the four fields are inside. The slip on the desk is the same four lines — pulled out of the drawer when you walked in. Your familiar has moved up a floor. Same key, every visit, one value: that is the prefix doing its job.

The prefix is a lifetime.

prefix

how long it lives

which floor

(none)

this visit

the desk

user:

every visit this visitor makes

floor two, your drawer

app:

everyone, forever

part of floor four

temp:

this turn only — never even climbs the stairs

—

One table, four drawers. This chapter touches one of them.

She knows you now

👉🖥 This visit is brand new and you have said nothing in it yet. Ask her:

What did I bring in last time?

👀 I have it written down that you brought in a lantern, case k7f2. It is marked with q7 and you mentioned it keeps going out at night. Every word of that came out of the drawer, not the conversation — this visit is one message old, and the message named nothing. That is user-level state doing its job: a user: key is there before the visitor says a word, on every visit they make.

The drawer holds the four lines the slip has, and nothing else. What you say that has no line on the slip — what you tried, why you care, whether you want a new one — has nowhere to go, and a month later it is gone. The drawer crosses the month. A sentence does not. Chapter 3 is about that.

What you wrote, and what it is

the line

what it is

where it lives

CASE = "user:case"

a prefix is a lifetime — the same key, scoped to the visitor instead of the visit

floor two · user: state

This rung reaches one visitor. The drawer follows you; the conversation still doesn't.

5. InMemoryMemoryService

  • What — read the line that files the day, write the line that asks; then one closing time and one question.
  • Why — the slip is a form. A form has the lines you gave it. People do not speak in lines — and filing what they said is only half of remembering it.
  • How — read file; one line in recall; then one 🌙, one 🗓, one question.

Closing time. She walks back past the shelves.

What she does on the way home — read only

Filed is not remembered

That line puts the day on the shelf. It does not take anything off it. A shelf full of cards helps exactly as much as an empty one if nobody looks — and as the code ships, nobody looks: recall, the node that runs before Vesper on every question, asks the tower nothing. Storage you cannot recall at the right moment is not memory; it is a log. The looking is the other half, and it is yours to write.

EDIT THREE — the asking

👉📖 In archive/agent.py, find recall — the function just above file. It is a plain function node that runs before Vesper on every question — not the model deciding when to look, which means the times it finds nothing are visible too. Read to the 👉:

    if query.strip():
        try:
            # 👉 EDIT THREE — chapter 3. Filed is not remembered: the cards are on
            #    the shelf and nobody is looking at them. Delete the "# " in front
            #    of the next line, then save:
            # found = await ctx.search_memory(query)
            pass
        except ValueError:
            # no memory service wired on this runner — degrade, do not crash
            pass

👉✏️ Still in archive/agent.py, inside recall: delete the # in front of one line — the # and the space after it. Before:

        try:
            # 👉 EDIT THREE — chapter 3. Filed is not remembered: the cards are on
            #    the shelf and nobody is looking at them. Delete the "# " in front
            #    of the next line, then save:
            # found = await ctx.search_memory(query)
            pass
        except ValueError:

After:

        try:
            # 👉 EDIT THREE — chapter 3. Filed is not remembered: the cards are on
            #    the shelf and nobody is looking at them. Delete the "# " in front
            #    of the next line, then save:
            found = await ctx.search_memory(query)
            pass
        except ValueError:

Leave the pass where it is — it does nothing now, because found is already None above.

Save. No restart.

Two lines, two policies — and you wrote one of them. The write policy decides what gets kept and when (closing time, the whole day). The recall policy decides when to look and with what (every question, the question itself). ADK gives you the verbs — add_events_to_memory, search_memory — and stays out of the policy. That is the right split, and it is why the asking was yours to write.

Closing time

👉🖥 Say three things the slip has no line for. One message or three:

I read by it at night, so night is exactly when I need it.
I've tried hanging it higher. No change.
I don't want a new one — it was a gift.

Press it here

👉🖥 Then press 🌙 that's all for today.

The words rise

👀 Watch the desk. The three things you said lift off the slip and float up, and land on floor three. Under the cards beside the tower they are listed — one card for each message you sent, marked quote, your sentences word for word, and in this process above them. The lamp on the desk stays lit until they have landed.

A month later, she knows

👉🖥 Press 🗓 a month later, and ask:

What have I already tried?

👀 You tried hanging it higher — it didn't help. She knows. The slip has no line for it and never did, so the drawer could not have answered this; recall found the card, wrote it into ctx.state["recalled"], and her instruction reads that. One line is the difference.

And it is all in her head

Look under the cards beside the tower: in this process. Right now the memory is InMemoryMemoryService — a dictionary inside bash valley.sh, and it lives exactly as long as that process does. Shut the tower tonight and open it tomorrow: the books are on floor one, the drawer is on floor two, and floor three is dark. The case survived in the file. What you said lived in the process, and the process is gone.

Persistence is not memory.

Two things are wrong with floor three as it stands: it is in the process, and it is in your own words — a quote, not a fact. The next chapter fixes both, with no code at all.

What you read, what you wrote, and what it is

the line

what it is

where it lives

await ctx.add_events_to_memory(events, custom_metadata=FILING) — you only read it

the write policy — a whole day, at closing time

floor three · the memory service

found = await ctx.search_memory(query) — EDIT THREE

the recall policy — every question, before she answers

recall() → ctx.state["recalled"]

This rung reaches what was said — and keeps it in a dictionary inside one process, in your own words. Both halves of that are wrong, and the next chapter fixes both with no code at all.

6. MemoryBank

  • What — one script builds a Memory Bank; the two lines you already have start writing facts to it and reading them back.
  • Why — a memory service is a service. Where the archive lives is a deployment decision, not an agent one — you change it without touching the agent.
  • How — run make_tower.py, say three sentences, press 🌙, press 🗓, ask one question. Then a read-only look at the write and the read.

Build the tower

Chapter 3's floor three was a Python dictionary inside bash valley.sh. Memory Bank is the managed version of that floor: it lives on an Agent Engine instance in your project, it keeps facts distilled from the day instead of your sentences, and it finds them by meaning instead of by shared words. You do not have to deploy Vesper to an Agent Engine to use it — an instance with nothing deployed on it still hosts a Memory Bank, and that is what the script builds: a tower with no one living in it, whose only job is to keep the cards.

👉💻 In tab 1 (about a minute):

cd ~/agent-valley-archive
uv run python scripts/make_tower.py

👀 Expect:

  building the tower in YOUR_PROJECT / us-central1 — about a minute...
  built, with the two topics in archive/topics.py configured on it.

  resource: projects/.../locations/us-central1/reasoningEngines/6792...
  AGENT_ENGINE written to .env

  Next — nothing to restart. The Archive reads .env on every message:
    look at floor three — under the cards it now says Memory Bank.
    (adk web, if you use it:  --memory_service_uri "agentengine://projects/.../reasoningEngines/6792...")

One SDK call, agent_engines.create(), and one line written into .env. The Archive re-reads that file on every request, so there is nothing to restart.

Watch it work

👉🖥 Look at floor three — the tower redraws on its own within a couple of seconds. Under the cards it now says Memory Bank, and the shelf is empty: the three quotes from chapter 3 were in the process, and this is not the process. Say the three sentences again, one message or three:

I read by it at night, so night is exactly when I need it.
I've tried hanging it higher. No change.
I don't want a new one — it was a gift.

👉🖥 Press 🌙 that's all for today. Closing time takes about eight seconds now — the tower is extracting. Then look at floor three.

Left: what she kept in her head. Right: what the tower keeps.

👀 The cards are not your sentences. They say things like I use my lantern to read at night, which is when it keeps going out and I have already tried hanging my lantern higher, each marked fact with a date. Distillation. What went in was a conversation. What came out is facts.

👉🖥 Press 🗓 a month later — a new visit, so nothing you just said is in the conversation any more — and ask her this, a question that shares not one word with the cards:

Should I just get a different lamp?

👀 Since it was a gift, you mentioned that you do not want to replace your lantern — and you have already tried hanging it higher. Every word of that came from the card, and only the card could have told her: this visit is one message old. Similarity search. The tower matches meaning, not words — the question said lamp and different, the card says lantern and replace. And it is out of the process now: shut the tower tonight and the cards are there tomorrow, because they live on the Agent Engine, not in bash valley.sh.

The write and the read — read only

The right to be forgotten

Press it here

👉🖥 Press 🔥 forget me, under the message box.

👀 Three floors go dark and one does not:

what it holds

after 🔥

floor three, the cards

what she remembered — the facts Memory Bank distilled from your visits

gone — memories.delete, one card at a time

floor two, the drawer

user:case, the slip that follows this visitor from visit to visit

cleared

the desk, the slip

this visit's copy of the case

cleared

floor one, the books

the visits themselves — one session in archive.db per visit, every message of it

untouched — the count does not move

The books are the record: that a visit happened, on which day, and what was said in it. The cards and the drawer are the memory: what she made of those visits and carries into the next one. A request to be forgotten is about the memory — the facts derived about a person, and the state that follows them around. Whether the record of a visit also goes is a separate decision, made for separate reasons — an audit trail, a support history — and this lab keeps it on purpose, so you can watch the two come apart in front of you: the record and the memory are not the same thing, and forgetting one does not touch the other. That is the sentence over Vesper's door, seen from the other side.

The ADK memory abstraction has add_events_to_memory and search_memory. It has no delete. Forgetting is a governance action, not an agent action, so it lives in the application — archive/tower.py, memories.delete on the raw client — where the model cannot reach it by accident. Whether an agent can forget is something you design, exactly like whether it can remember.

What you built and read, and what it is

the thing

what it is

where it lives

make_tower.py → AGENT_ENGINE in .env — built, not edited, nothing restarted

the memory service, as a service: a deployment decision, outside the agent

Vertex AI Memory Bank on an Agent Engine

tower_service(engine) — you only read it

the same Runner(memory_service=...) slot, a different home

archive/service.py

"allowed_topics": TOPICS in FILING — read only

what may be kept, decided at write time, not in the prompt

archive/topics.py + the tower's own config

🔥 → memories.delete — pressed, not written

forgetting is an application action; ADK's memory API has no delete

archive/tower.py

This rung reaches what was said. A day goes in; a fact comes out — distilled to what the topics allow — and the book on floor one never moved.

7. GraphRAG

Optional.

  • What — one script loads the valley's past into BigQuery, generates embeddings inside the warehouse and lays a graph over the tables without moving a row; then two questions to Vesper, who has two governed queries for it: one for what sounds alike, one for what is connected.
  • Why — sounds alike is not the same as is connected. A vector finds where to look; a graph finds what is attached; and the answer has to carry the path it walked.
  • How — bash scripts/season.sh, once. Everything else in this chapter is read only.

Vesper's tower is scoped to you — (app_name, user_id) — by construction. Other people's visits are not her memory, and searching it harder would not change that. They are the valley's, and the valley keeps them in a warehouse. Thirty-one visitors this season came about a q7 lantern going out at night, and one of them found the cause. This chapter loads that warehouse and hands her two fixed queries into it.

Graph RAG

RAG is retrieve, then generate: find the right records, hand them to the model, let it answer from them. Plain RAG retrieves by similarity — embed the question, pull the nearest records. That finds things that sound like the question, and it is where chapter 4's tower stopped. Graph RAG adds a second retrieval step that follows edges — keys, which a warehouse already has — from whatever similarity found, to whatever is attached to it. The two steps answer different questions, and this chapter makes you ask both:

step

the question it answers

how

in this chapter

similarity

where should I look?

embed the question, VECTOR_SEARCH

season_search — when the visitor brings no mark

traversal

what is connected to that?

MATCH a path through a property graph

season_known_issue — one fixed MATCH

generation

what do I tell the visitor?

the model, with the path as its citation

her answer

Run it

The season ships as plain CSVs in season/ — visitors, items, asks, fixes, seasons — the way a support system would export them. One script takes them all the way into the warehouse, and it is the only thing you run in this chapter.

👉💻 In tab 1 (about three minutes; it prints each step as it goes, and stops on the first one that fails):

cd ~/agent-valley-archive
bash scripts/season.sh

👀 When it ends, floor four on the tower lights up, and the legend beside it says open.

What it built — read only

How she reads it — read only

Ask her

👉🖥 Floor four is open on the tower. Ask her the way any visitor would:

Has anyone else had a q7 lantern go out at night?

She answers, and the last line is the path

👀 She answers — thirty-one others... most said the flame sat low and went out after an hour... Marlow the otter found the wicks were dipped too shallow — and the last line of her answer is the path. This is her output — read it, do not send it back to her:

mark q7 → 31 visits this season → 10 different things said → the wicks in the q7 batch were dipped too shallow — they drown in the oil and starve in an hour

The path is the citation. Every hop in it is an edge in the graph, and an edge is a key, so anyone can walk it again and get the same thirty-one. A similarity score cannot be walked again by anyone. If she says, just before the path, that the rows came back through three joins because the project has no graph reservation, that is your project's edition refusing GRAPH_TABLE — the rows and the path are the same.

👉🖥 Now leave the mark out — send this:

Has anyone else had a lantern that keeps dying once the sun is down?

👀 She gets to the same thirty-one, and the same path. What changed is the road: season_search ran first, found five visitors who sound alike — each carrying q7 — took that mark, and only then walked. Similar is not connected; similar is where to look.

What was loaded, and what it is

the thing

what it is

where it lives

bq load with explicit schemas — run, not written

a warehouse: columns with names and types

archive.visitors / items / asks / fixes / seasons

ML.GENERATE_EMBEDDING + VECTOR_SEARCH

the similarity step — where to look

archive.ask_embeddings, the embedder model

CREATE PROPERTY GRAPH — no row moved

the traversal step — what is connected; tables become nodes and arrows

archive.season_graph

GQL in archive/season/__init__.py — one parameter

the only walk she may take; the path is the citation

season_known_issue(mark)

A few dozen visitors is not a reason to buy a warehouse. What this chapter teaches is the shape of the query — not scale. Grow this graph a thousand times and the MATCH does not change by a character.

8. Close

Three lines, one tower, one warehouse — four floors. Nothing restarted.

the rung

what you did

what it bought

ONE

tool_context.state[CASE] = case — an edit

a slip that exists at all: state is written by code

TWO

CASE = "user:case" — an edit

a drawer that follows the visitor

the filing

await ctx.add_events_to_memory(..., custom_metadata=FILING) — read, not written

what was said, kept

THREE

found = await ctx.search_memory(query) — an edit

what was kept, asked for — filed is not remembered

the tower

make_tower.py, then AGENT_ENGINE in .env — built and connected by one line, nothing restarted

a tower that distills, and survives the night

the rules

allowed_topics in FILING — read, not written

only what should be kept

the season

season.sh — loaded, embedded in place, a graph laid over it, walked with one MATCH

everyone else's visits, by meaning and by connection

What each piece replaced

ADK 2

what you would write instead

what that costs you

session.state + one tool, kept by SqliteSessionService

a dict beside the chat loop, pickled to disk yourself

the model cannot read it, and you write the resume and the locking

the user: prefix

a users table and a join on every turn

deciding where a key belongs, every single time

add_events_to_memory + search_memory in a function node

a cron that dumps transcripts, and the model deciding when to look

it dumps, nothing distills — and you never see the times it didn't look

VertexAiMemoryBankService + allowed_topics

a vector store, an extraction prompt, a consolidation job, and "please don't remember trivia" in the prompt

three services, and the rule holds until the model feels otherwise

VECTOR_SEARCH + one fixed GQL MATCH

export, embed elsewhere, a graph database, a sync job, and the model writing SQL

the data leaves the warehouse to be understood, and you find out which joins it guessed when a customer does

Three questions, stop at the first yes

  1. Will it matter tomorrow? No → keep nothing. It lives and dies in the conversation.
  2. Can a function check it? Yes → the drawer. Code writes it, every turn reads it, and no sentence can change it.
  3. Otherwise → the tower. Distilled from what was said, looked up when it is asked for, revised by what is said next.

The one sentence

A record is not a memory. A memory is a record plus two decisions — what to keep, and when to look — and what kind of thing it is decides which floor it goes on.

Facts and preferences belong in different stores.

👀 Every floor is lit. Your familiar is at the top. The Archive stamp is in the passport, and week five opens the Night Market — where Nix runs the stage out loud, in real time, and everything the tower knows comes with you.