Chapter 7 — Episodic Memory¶
Setup Instructions¶
To ensure you have the required dependencies to run this notebook, you'll need to have our llm-agents-from-scratch framework installed on the running Jupyter kernel. To do this, you can launch this notebook with the following command while within the project's root directory:
uv run --with jupyter jupyter lab
Alternatively, if you just want to use the published version of llm-agents-from-scratch without local development, you can install it from PyPi by uncommenting the cell below.
Tip: If you're running the notebook on a Windows machine, Git only checks out symlinks as real links when symlink support is turned on in Git (the installer's "Enable symbolic links" option, or cloning with
git clone -c core.symlinks=true) and Windows allows creating them (Developer Mode, or an administrator shell). Otherwise, the.agentsfolder contained in this chapter's notebook directory ends up as a plain text file. In that case, delete it and copychapter-notebooks/shared/.agentsinto this folder.
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
from llm_agents_from_scratch.notebook_utils import make_llm
Examples¶
Example 1: Constructing an Episode and Printing Format Modes¶
from llm_agents_from_scratch.data_structures import Task, TaskResult
from llm_agents_from_scratch.data_structures.memory import Episode, EpisodeFormatMode
task = Task(instruction="Compute the Hailstone sequence for 6.")
result = TaskResult(task_id=task.id_, content="6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1")
episode = Episode(
task=task,
rollout="mock rollout",
result=result,
metadata={"reflection": "Always use the hailstone tool."},
)
print("=== XML (default) ===")
print(episode.format(mode=EpisodeFormatMode.XML))
print()
print("=== CONCAT ===")
print(episode.format(mode=EpisodeFormatMode.CONCAT))
=== XML (default) ===
<episode>
<task>Compute the Hailstone sequence for 6.</task>
<result>6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1</result>
<reflection>Always use the hailstone tool.</reflection>
<completed_at>2026-10-11 14:54:15</completed_at>
</episode>
=== CONCAT ===
task: Compute the Hailstone sequence for 6.
result: 6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1
reflection: Always use the hailstone tool.
completed_at: 2026-10-11 14:54:15
Example 2a: Writing Episodes to a QdrantMemoryStore¶
import warnings
from llm_agents_from_scratch.data_structures import Task, TaskResult
from llm_agents_from_scratch.data_structures.memory import Episode
from llm_agents_from_scratch.memory_stores.qdrant import QdrantMemoryStore
warnings.filterwarnings("ignore", message="Payload indexes have no effect")
store = QdrantMemoryStore(max_results=1)
task_1 = Task(instruction="Compute the Hailstone sequence for 6.")
task_2 = Task(instruction="Compute the Hailstone sequence for 8.")
episodes = [
Episode(
task=task_1,
rollout="mock rollout",
result=TaskResult(
task_id=task_1.id_,
content="6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1",
),
metadata={"steps": "8"},
),
Episode(
task=task_2,
rollout="mock rollout",
result=TaskResult(
task_id=task_2.id_,
content="8 → 4 → 2 → 1",
),
metadata={"steps": "3"},
),
]
for ep in episodes:
await store.write(ep)
print(f"count: {await store.count()}")
count: 1 count: 2
Example 2b: Recalling from a QdrantMemoryStore¶
results = await store.recall("Hailstone sequence starting from 6")
print(
f"recall() returned {len(results)} episode(s)"
" -- query matched by similarity (RecallMode.SEARCH)\n"
)
print(results[0].format())
recall() returned 1 episode(s) -- query matched by similarity (RecallMode.SEARCH)
<episode>
<task>Compute the Hailstone sequence for 6.</task>
<result>6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1</result>
<steps>8</steps>
<completed_at>2026-10-11 14:54:15</completed_at>
</episode>
Example 3: Memory with a metadata_fn¶
import warnings
from llm_agents_from_scratch.data_structures import Task, TaskResult
from llm_agents_from_scratch.data_structures.memory import Episode
from llm_agents_from_scratch.memory import Memory
from llm_agents_from_scratch.memory_stores.qdrant import QdrantMemoryStore
warnings.filterwarnings("ignore", message="Payload indexes have no effect")
task = Task(instruction="Compute the Hailstone sequence for 6.")
episode = Episode(
task=task,
rollout="mock rollout",
result=TaskResult(
task_id=task.id_,
content="6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1",
),
)
def step_counter_fn(ep: Episode) -> str:
return str(len(ep.result.content.split("→")))
store = QdrantMemoryStore(max_results=1)
memory = Memory(store=store, metadata_fns={"steps": step_counter_fn})
await memory.record(episode)
# record() works on a deep copy — the original episode is not mutated
print("Original episode:")
print(episode)
recalled = await memory.recall(Task(instruction="Compute the Hailstone sequence for 3."))
print("\nRecalled episode (enriched by step_counter_fn at write time):")
print(recalled)
Original episode:
<episode>
<task>Compute the Hailstone sequence for 6.</task>
<result>6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1</result>
<completed_at>2026-10-11 14:54:15</completed_at>
</episode>
Recalled episode (enriched by step_counter_fn at write time):
<episode>
<task>Compute the Hailstone sequence for 6.</task>
<result>6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1</result>
<steps>9</steps>
<completed_at>2026-10-11 14:54:15</completed_at>
</episode>
Example 4: LLMAgent with similarity_memory()¶
import logging
import warnings
from llm_agents_from_scratch import LLMAgent
from llm_agents_from_scratch.data_structures import Task
from llm_agents_from_scratch.logger import enable_console_logging
from llm_agents_from_scratch.memory.recipes import similarity_memory
from llm_agents_from_scratch.tools import SimpleFunctionTool
warnings.filterwarnings("ignore", message="Payload indexes have no effect")
enable_console_logging(logging.INFO)
def next_number(n: int) -> int:
"""Returns the next number in the stop-at-one sequence."""
return n // 2 if n % 2 == 0 else 3 * n + 1
next_number_tool = SimpleFunctionTool(func=next_number)
llm = make_llm()
memory = similarity_memory(max_results=1)
agent = LLMAgent(llm=llm, tools=[next_number_tool], memories=[memory])
✓ Using Ollama Cloud (deepseek-v4.1-flash)
agent.llm.model
'deepseek-v4.1-flash'
# Task 1: no memory yet — the agent computes the sequence via tools and
# similarity_memory records the episode in the Qdrant vector store.
result_1 = await agent.run(
Task(
instruction=(
"Compute the stop-at-one sequence for 4"
)
),
)
print(result_1.content)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: Compute the stop-at-one sequence for 4
INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: Compute the stop-at-one sequence for 4
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: from_scratch__use_skill
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: <skill_content name="stop-at-one">
# Stop At One
Compute a full sequence from a starting number down to 1
using the `next_num...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: I need to make the following tool-calls:
{
"id_": "78aeae0e-7e18-46af-a19d-a06f70db0f49",
"tool_name": "next_number",
"argu...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : 🧠 New Step: Call the `next_number` tool with the argument n=4, then wait for the tool result before continuing.
INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: Call the `next_number` tool with the argument n=4, then wait for the tool result before continuing.
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: next_number
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: 2
INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: I need to make the following tool-calls:
{
"id_": "b4a7ca21-0b5f-4365-90a2-ad8ac6e29261",
"tool_name": "next_number",
"argu...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : 🧠 New Step: Call the `next_number` tool with the argument n=2, then wait for the tool result before continuing.
INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: Call the `next_number` tool with the argument n=2, then wait for the tool result before continuing.
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: next_number
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: 1
INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: The sequence has reached 1, so I can now report the complete result.
**Sequence for starting number 4:**
```
4 → 2 → 1
```
- **Start...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : No new step required.
INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: The sequence has reached 1, so I can now report the complete result.
**Sequence for starting number 4:**
```
4 → 2 → 1
```
- **St...[TRUNCATED]
The sequence has reached 1, so I can now report the complete result.
**Sequence for starting number 4:**
```
4 → 2 → 1
```
- **Starting number:** 4
- **Total steps taken:** 2 tool calls
- **Maximum value reached:** 4
# Inspect what will be injected into the system prompt as memory for Task 2.
# This is the same call the agent makes internally via memory.recall() before
# its first step — surfacing it here makes the memory context visible.
task_2 = Task(
instruction=(
"What is the stop-at-one sequence for 4?"
)
)
print("Memory recalled for Task 2:\n")
print(await memory.recall(task_2))
Memory recalled for Task 2:
<episode>
<task>Compute the stop-at-one sequence for 4</task>
<result>The sequence has reached 1, so I can now report the complete result.
**Sequence for starting number 4:**
```
4 → 2 → 1
```
- **Starting number:** 4
- **Total steps taken:** 2 tool calls
- **Maximum value reached:** 4</result>
<completed_at>2026-10-11 14:54:22</completed_at>
</episode>
# Task 2: the episode from Task 1 is recalled by similarity search and
# injected into the system prompt — the agent answers directly from memory.
result_2 = await agent.run(
task_2,
)
print(result_2.content)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: What is the stop-at-one sequence for 4? INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: What is the stop-at-one sequence for 4? INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: I recall an episode covering exactly this task: computing the stop-at-one sequence for 4. The result was 4 → 2 → 1. The sequence for s...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : No new step required. INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: I recall an episode covering exactly this task: computing the stop-at-one sequence for 4. The result was 4 → 2 → 1. The sequence fo...[TRUNCATED] I recall an episode covering exactly this task: computing the stop-at-one sequence for 4. The result was 4 → 2 → 1. The sequence for starting number 4 is: **4 → 2 → 1**