Episodic Memory — Research Accumulation and Persistence¶
This notebook demonstrates the recency_memory() factory, which gives an
LLMAgent episodic memory that accumulates across tasks and survives agent
restarts.
Two properties are illustrated:
- Recall across tasks. After each task the agent records an episode.
Subsequent tasks receive the most recent
max_resultsepisodes injected into their system prompt, so the agent can answer synthesis questions without repeating tool calls. - Persistence across restarts. Episodes are written to a JSONL file on disk. A brand-new agent instance pointing to the same file picks up all prior episodes immediately.
This notebook uses the same PokéAPI tool as
ch04/pokemon_comparison.ipynb. Reading that notebook first is recommended.
Setting up the LLM¶
I recommend running these notebooks on Ollama Cloud. It serves much larger models than most laptops can run. The default cloud model is picked by benchmarking candidates on these notebooks, and an automated run checks every notebook against it each week. The notebook status page shows the current model and results.
- Create an API key at https://ollama.com/settings/keys.
- Export it before launching Jupyter:
export OLLAMA_API_KEY=<your key>. - That's all. When a notebook builds its LLM, it detects the key and uses Ollama Cloud.
Prefer to run locally? Install Ollama from https://ollama.com/download
and pull the model with ollama pull qwen3:14b. If no key is set, the
notebook starts the Ollama service for you, so there is no need to run
ollama serve. Local models are smaller, so your outputs will differ from
the recorded ones.
Prefer OpenAI or Anthropic?
- Install the extra:
uv sync --extra openaioruv sync --extra anthropic. - Export
OPENAI_API_KEYorANTHROPIC_API_KEYbefore launching Jupyter. If you forget, you will be prompted for it, which keeps the key out of the saved notebook. - Set
LLM_PROVIDER=openai(oranthropic), or passprovider="openai"tomake_llm(). A key on its own never switches providers, so one exported for unrelated work cannot reroute you.
The switch applies wherever a notebook builds its LLM with make_llm().
A notebook that constructs OllamaLLM directly stays on Ollama. Output on
gpt-5 or Claude will differ from the recorded outputs, and prompt-sensitive
examples (the ch09 evaluator pattern, ch08 supervised trajectories) may
behave noticeably differently.
# Uncomment the line below to install `llm-agents-from-scratch` from PyPI
# !pip install llm-agents-from-scratch
Setup¶
from llm_agents_from_scratch.notebook_utils import make_llm
import json
import logging
import urllib.error
import urllib.request
from pathlib import Path
from llm_agents_from_scratch import LLMAgent
from llm_agents_from_scratch.data_structures import Task
from llm_agents_from_scratch.data_structures.memory import Episode
from llm_agents_from_scratch.logger import enable_console_logging
from llm_agents_from_scratch.memory.recipes import recency_memory
from llm_agents_from_scratch.tools.simple_function import SimpleFunctionTool
enable_console_logging(logging.INFO)
Defining the Tool¶
def get_pokemon(name: str) -> str:
"""Look up a Pokémon by name and return its types and base stats."""
url = f"https://pokeapi.co/api/v2/pokemon/{name.lower().strip()}"
req = urllib.request.Request(
url,
headers={"User-Agent": "llm-agents-from-scratch/1.0"},
)
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404: # noqa: PLR2004
raise ValueError(
f"Pokémon '{name}' not found. "
"Check the spelling and try again.",
) from e
raise
types = [t["type"]["name"] for t in data["types"]]
stats = {s["stat"]["name"]: s["base_stat"] for s in data["stats"]}
return json.dumps({"name": data["name"], "types": types, "stats": stats})
get_pokemon_tool = SimpleFunctionTool(func=get_pokemon)
STORE_DIR = Path(".")
STORE_DIR.mkdir(exist_ok=True)
(STORE_DIR / "episodes.jsonl").unlink(
missing_ok=True,
) # start clean on each full run
Creating the Agent with Memory¶
recency_memory() returns a Memory instance backed by a JSONL file at
STORE_DIR. It recalls the max_results most recent episodes and formats
them for the system prompt. The agent holds the memory in its memories
list; TaskHandler calls recall at task start and record at task end
automatically.
memory = recency_memory(path=STORE_DIR, max_results=3)
llm = make_llm()
agent = LLMAgent(llm=llm, tools=[get_pokemon_tool], memories=[memory])
✓ Using Ollama Cloud (kimi-k2.7-code:cloud)
Part 1 — Building Up Episodic Memory¶
Three sequential Pokémon lookups. Each task calls get_pokemon once and records
an episode at the end. The memory summary after each task shows the store count
growing from 0 to 3. By the time the fourth task runs, all three episodes
will be recalled into its system prompt.
task1 = Task(
instruction=(
"What are Pikachu's base stats? "
"Use the get_pokemon tool. Do not rely on prior knowledge."
),
)
result1 = await agent.run(task1)
print(result1)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: What are Pikachu's base stats? Use the get_pokemon tool. Do not rely on prior knowledge.
INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: What are Pikachu's base stats? Use the get_pokemon tool. Do not rely on prior knowledge.
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: get_pokemon
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: {"name": "pikachu", "types": ["electric"], "stats": {"hp": 35, "attack": 55, "defense": 40, "special-attack": 50, "special-def...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: Pikachu's base stats are:
- **HP:** 35
- **Attack:** 55
- **Defense:** 40
- **Special Attack:** 50
- **Special Defense:** 50
- **Speed...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : No new step required.
INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: Pikachu's base stats are:
- **HP:** 35
- **Attack:** 55
- **Defense:** 40
- **Special Attack:** 50
- **Special Defense:** 50
- **Sp...[TRUNCATED]
Pikachu's base stats are:
- **HP:** 35
- **Attack:** 55
- **Defense:** 40
- **Special Attack:** 50
- **Special Defense:** 50
- **Speed:** 90
Pikachu is an **Electric** type.
print(await agent.memories[0].summary())
JSONMemoryStore: 1 episodes | path=episodes.jsonl newest: 2026-09-29 23:15:51 | What are Pikachu's base stats? Use the get_pokemon tool. Do oldest: 2026-09-29 23:15:51 | What are Pikachu's base stats? Use the get_pokemon tool. Do metadata_fns: none
task2 = Task(
instruction=(
"What are Gengar's base stats? "
"Use the get_pokemon tool. Do not rely on prior knowledge."
),
)
result2 = await agent.run(task2)
print(result2)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: What are Gengar's base stats? Use the get_pokemon tool. Do not rely on prior knowledge.
INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: What are Gengar's base stats? Use the get_pokemon tool. Do not rely on prior knowledge.
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: get_pokemon
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: {"name": "gengar", "types": ["ghost", "poison"], "stats": {"hp": 60, "attack": 65, "defense": 60, "special-attack": 130, "spec...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: Gengar's base stats are:
- **HP:** 60
- **Attack:** 65
- **Defense:** 60
- **Special Attack:** 130
- **Special Defense:** 75
- **Speed...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : No new step required.
INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: Gengar's base stats are:
- **HP:** 60
- **Attack:** 65
- **Defense:** 60
- **Special Attack:** 130
- **Special Defense:** 75
- **Sp...[TRUNCATED]
Gengar's base stats are:
- **HP:** 60
- **Attack:** 65
- **Defense:** 60
- **Special Attack:** 130
- **Special Defense:** 75
- **Speed:** 110
Gengar is a **Ghost/Poison** type.
print(await agent.memories[0].summary())
JSONMemoryStore: 2 episodes | path=episodes.jsonl newest: 2026-09-29 23:15:55 | What are Gengar's base stats? Use the get_pokemon tool. Do n oldest: 2026-09-29 23:15:51 | What are Pikachu's base stats? Use the get_pokemon tool. Do metadata_fns: none
task3 = Task(
instruction=(
"What are Mewtwo's base stats? "
"Use the get_pokemon tool. Do not rely on prior knowledge."
),
)
result3 = await agent.run(task3)
print(result3)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: What are Mewtwo's base stats? Use the get_pokemon tool. Do not rely on prior knowledge.
INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: What are Mewtwo's base stats? Use the get_pokemon tool. Do not rely on prior knowledge.
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: get_pokemon
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: {"name": "mewtwo", "types": ["psychic"], "stats": {"hp": 106, "attack": 110, "defense": 90, "special-attack": 154, "special-de...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: Mewtwo's base stats are:
- **HP:** 106
- **Attack:** 110
- **Defense:** 90
- **Special Attack:** 154
- **Special Defense:** 90
- **Spe...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : No new step required.
INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: Mewtwo's base stats are:
- **HP:** 106
- **Attack:** 110
- **Defense:** 90
- **Special Attack:** 154
- **Special Defense:** 90
- **...[TRUNCATED]
Mewtwo's base stats are:
- **HP:** 106
- **Attack:** 110
- **Defense:** 90
- **Special Attack:** 154
- **Special Defense:** 90
- **Speed:** 130
Mewtwo is a **Psychic** type.
print(await agent.memories[0].summary())
JSONMemoryStore: 3 episodes | path=episodes.jsonl newest: 2026-09-29 23:16:00 | What are Mewtwo's base stats? Use the get_pokemon tool. Do n oldest: 2026-09-29 23:15:51 | What are Pikachu's base stats? Use the get_pokemon tool. Do metadata_fns: none
Part 2 — Synthesis from Recalled Episodes¶
The synthesis task asks the agent to compare all three Pokémon by speed. No stats
are provided in the task instruction. The information is only available through
the recalled episodes injected into the system prompt at the start of this run.
A working memory means the agent can answer without calling get_pokemon again.
Watch the logs: you should see no 🛠️ Executing Tool Call lines.
task4 = Task(
instruction=(
"Based on the Pokémon you have already researched, "
"which one has the highest base speed? "
"You do not need to call any tools. Use only what you already know."
),
)
result4 = await agent.run(task4)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: Based on the Pokémon you have already researched, which one has the highest base speed? You do not need to call any tools. Use only w...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: Based on the Pokémon you have already researched, which one has the highest base speed? You do not need to call any tools. Use onl...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: From the Pokémon I've already researched: - **Pikachu:** Speed 90 - **Gengar:** Speed 110 - **Mewtwo:** Speed 130 **Mewtwo** has the ...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : No new step required. INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: From the Pokémon I've already researched: - **Pikachu:** Speed 90 - **Gengar:** Speed 110 - **Mewtwo:** Speed 130 **Mewtwo** has t...[TRUNCATED]
print(result4)
From the Pokémon I've already researched: - **Pikachu:** Speed 90 - **Gengar:** Speed 110 - **Mewtwo:** Speed 130 **Mewtwo** has the highest base speed at **130**.
Part 3 — Inspecting the Store on Disk¶
JSONMemoryStore writes one JSON line per episode. The cell below reads the raw
file so you can see exactly what is persisted between tasks and what a new
agent instance will load on the next run.
lines = (STORE_DIR / "episodes.jsonl").read_text().splitlines()
print(f"{len(lines)} episode(s) on disk\n")
for line in lines:
episode = Episode.model_validate_json(line)
print(str(episode))
print()
4 episode(s) on disk
<episode>
<task>What are Pikachu's base stats? Use the get_pokemon tool. Do not rely on prior knowledge.</task>
<result>Pikachu's base stats are:
- **HP:** 35
- **Attack:** 55
- **Defense:** 40
- **Special Attack:** 50
- **Special Defense:** 50
- **Speed:** 90
Pikachu is an **Electric** type.</result>
<completed_at>2026-09-29 23:15:51</completed_at>
</episode>
<episode>
<task>What are Gengar's base stats? Use the get_pokemon tool. Do not rely on prior knowledge.</task>
<result>Gengar's base stats are:
- **HP:** 60
- **Attack:** 65
- **Defense:** 60
- **Special Attack:** 130
- **Special Defense:** 75
- **Speed:** 110
Gengar is a **Ghost/Poison** type.</result>
<completed_at>2026-09-29 23:15:55</completed_at>
</episode>
<episode>
<task>What are Mewtwo's base stats? Use the get_pokemon tool. Do not rely on prior knowledge.</task>
<result>Mewtwo's base stats are:
- **HP:** 106
- **Attack:** 110
- **Defense:** 90
- **Special Attack:** 154
- **Special Defense:** 90
- **Speed:** 130
Mewtwo is a **Psychic** type.</result>
<completed_at>2026-09-29 23:16:00</completed_at>
</episode>
<episode>
<task>Based on the Pokémon you have already researched, which one has the highest base speed? You do not need to call any tools. Use only what you already know.</task>
<result>From the Pokémon I've already researched:
- **Pikachu:** Speed 90
- **Gengar:** Speed 110
- **Mewtwo:** Speed 130
**Mewtwo** has the highest base speed at **130**.</result>
<completed_at>2026-09-29 23:16:04</completed_at>
</episode>
Part 4 — Persistence Across Agent Restarts¶
A fresh LLMAgent is created pointing at the same STORE_PATH. No state is
shared with the previous instance. The new agent loads its episodes entirely
from disk. The summary shows it starts with three episodes already in memory,
and it can answer the synthesis question without calling any tools.
memory2 = recency_memory(path=STORE_DIR, max_results=3)
llm2 = make_llm()
agent2 = LLMAgent(llm=llm2, tools=[get_pokemon_tool], memories=[memory2])
✓ Using Ollama Cloud (kimi-k2.7-code:cloud)
print(await agent2.memories[0].summary())
JSONMemoryStore: 4 episodes | path=episodes.jsonl newest: 2026-09-29 23:16:04 | Based on the Pokémon you have already researched, which one oldest: 2026-09-29 23:15:51 | What are Pikachu's base stats? Use the get_pokemon tool. Do metadata_fns: none
task5 = Task(
instruction=(
"Which Pokémon have you previously researched, and which one had "
"the highest base speed? "
"You do not need to call any tools. Use only what you already know."
),
)
result5 = await agent2.run(task5)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: Which Pokémon have you previously researched, and which one had the highest base speed? You do not need to call any tools. Use only w...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: Which Pokémon have you previously researched, and which one had the highest base speed? You do not need to call any tools. Use onl...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: From the Pokémon I've previously researched: - **Pikachu:** Speed 90 - **Gengar:** Speed 110 - **Mewtwo:** Speed 130 **Mewtwo** has t...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : No new step required. INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: From the Pokémon I've previously researched: - **Pikachu:** Speed 90 - **Gengar:** Speed 110 - **Mewtwo:** Speed 130 **Mewtwo** ha...[TRUNCATED]
print(result5)
From the Pokémon I've previously researched: - **Pikachu:** Speed 90 - **Gengar:** Speed 110 - **Mewtwo:** Speed 130 **Mewtwo** has the highest base speed at **130**.
Key Takeaway¶
recency_memory() gives the agent two things it would otherwise lack:
- Context across tasks. Every task after the first starts with the
max_resultsmost recent episodes injected into its system prompt. The agent can synthesise across past work without repeating tool calls (see Part 2 and Part 4). - Persistence across restarts. Episodes are written to a JSONL file immediately after each task. Any new agent pointing at the same path resumes with full context — nothing is lost when the process ends.
The factory bundles the two layers that deliver these properties: a
recency-based recall strategy that selects the N most recent episodes, and
a file-backed store that handles persistence. Both are configured through
the factory — path sets the storage directory; max_results controls
the recall window.