Give Your Agent a Memory: State, Steps, and Inspecting Runs (smolagents, Module 6)
This is Module 6 of smolagents Mastery, a free 15-module course that takes you from your first code agent to a production-grade one. Start at Module 1 or browse the full syllabus.
You ask Quill, “Which category grew fastest from Q1 to Q4 2025?” It loads the CSV, profiles it, writes some pandas, draws a chart, and answers: Team. Good. Then you ask the obvious follow-up: “Now exclude any rows from 2020.” And Quill… reloads the entire CSV, re-runs profile_dataframe, re-derives everything from scratch. It has no idea you were just talking.
It gets worse. Two steps ago the model ran print(df.head(50)). That 50-row dump is now sitting in an Observation: block — and smolagents re-sends it to the model on every subsequent step. You pay for that same DataFrame ten times over. On a long session the context balloons, latency climbs, and the bill climbs with it.
Both problems have the same root and the same fix. A smolagents agent’s memory is not a magic black box — it is a plain Python list of steps you can read, continue, and edit. This module teaches you to do exactly that.
In this module
You’ll learn:
- Explain how a smolagents agent remembers:
AgentMemoryis asystem_promptplus a list of typed steps, andwrite_memory_to_messages()turns that list back into the chat messages the model sees on the next step. - Read and inspect a run —
agent.memory.steps,get_succinct_steps()/get_full_steps(),return_full_code(),agent.replay(), andagent.visualize()— instead of guessing why your agent did what it did. - Continue a conversation across turns with
reset=False, and predict exactly what the agent does and doesn’t remember. - Engineer the context by mutating memory in a
step_callback(signature(memory_step, agent)): prune stale observations to save tokens, and log per-step token usage. - Distinguish the streaming surface (
stream=Trueyields steps,stream_outputs=Trueyields token deltas) from the internal generators (_run_stream/_step_stream/step()) that produce it.
You’ll build: Quill goes multi-turn. You add quill/callbacks.py — a pair of step callbacks that prune large DataFrame dumps from old observations and log cost per step — then run Quill with reset=False so it remembers the previous dataset, and replay the whole trajectory to see exactly what it kept.
Theory areas covered: T6 — Memory, state, steps & inspection — 8% of the course.
Prerequisites: Module 2 (the ReAct loop, what a step is, final_answer), Module 3 (Quill’s data tools), Module 4 (make_model()), Module 5 (the sandbox, with build_quill() as agent:). A Hugging Face token in .env (HF_TOKEN).
Where we are
- ✅ Modules 1–5: a
CodeAgent, a data toolbox, themake_model()factory, a real sandbox. - 👉 Module 6: memory, state, and inspecting runs (you are here).
- ⬜ Modules 7–15: planning, reliable outputs, MCP, multi-agents, vision, RAG, UI, telemetry, capstone.
Quill before this module: sandboxed, but amnesiac — every run() starts from zero, and every step re-sends old DataFrame dumps to the model. Quill after this module: multi-turn — reset=False carries the loaded dataset and prior findings forward, a step callback prunes the stale dumps to keep the bill down, and you can replay the whole trajectory to see what it kept.
What “memory” actually is: a list of steps
Here is the single most useful thing to internalize about smolagents memory: there is no separate state object. Other agent frameworks hand you a state dictionary you thread through nodes. smolagents does not. The memory is the state, and the memory is a Python list.
Concretely, AgentMemory (the object at agent.memory) holds exactly two things, straight from the 1.26.0 source:
class AgentMemory:
def __init__(self, system_prompt: str):
self.system_prompt: SystemPromptStep = SystemPromptStep(system_prompt=system_prompt)
self.steps: list[TaskStep | ActionStep | PlanningStep] = []
That is the whole shape. A system prompt, and a list of typed steps. Run Quill once and look:
from smolagents import ActionStep
from quill.agent import build_quill, build_task
with build_quill() as agent:
agent.run(build_task("data/sales.csv", "Which category has the highest net_rev?"))
for step in agent.memory.steps:
print(type(step).__name__, getattr(step, "step_number", ""))
TaskStep
ActionStep 1
ActionStep 2
ActionStep 3
The TaskStep is your question. Each ActionStep is one turn of the ReAct loop — think, write code, run it, observe. The system prompt lives separately at agent.memory.system_prompt; it is not in the steps list.
AgentMemory gives you four methods worth knowing:
reset()— clearssteps, keeps the system prompt. This is whatrun(reset=True)calls.get_succinct_steps()— every step as a dict, minus themodel_input_messagesfield (the bulky raw prompt the model saw). Use this when you want a readable trace.get_full_steps()— the same, withmodel_input_messages. Heavy, but complete.return_full_code()— concatenates everycode_actionfrom everyActionStepinto one script. For aCodeAgent, this is gold: it is literally every line of Python the agent wrote, in order.
print(agent.memory.return_full_code())
import pandas as pd
df = pd.read_csv("data/sales.csv")
print(df.groupby("category")["net_rev"].sum())
totals = df.groupby("category")["net_rev"].sum().sort_values()
final_answer(f"{totals.index[-1]} has the highest net revenue")
The five step types
smolagents has exactly five step types, all subclasses of MemoryStep. Here are their key fields, copied from memory.py at the v1.26.0 tag — no invented names:
SystemPromptStep—system_prompt: str. The instructions. Stored onmemory.system_prompt, not insteps.TaskStep—task: str,task_images: list[PIL.Image.Image] | None. The user’s question (plus any input images — vision input is Module 11; heretask_imagesstaysNone).ActionStep— the core step of the loop. Dissected below.PlanningStep—plan: strplusmodel_input_messages,model_output_message,timing,token_usage. A periodic planning step. It appears instepsonly when planning is enabled — we just name the type here; configuringplanning_intervalis Module 7.FinalAnswerStep—output: Any. The terminal item. This one is special: it is the last thingrun(stream=True)yields, but it is not appended tomemory.steps. More on that distinction shortly.
Two base methods every step shares: dict() (serialize it) and to_messages() (turn this one step into chat messages). That second method is the hinge of the whole module.
write_memory_to_messages: where the list becomes a prompt
write_memory_to_messages() is the function that explains everything. Every step, before it calls the model, the agent runs it. From the source:
def write_memory_to_messages(self, summary_mode: bool = False) -> list[ChatMessage]:
"""
Reads past llm_outputs, actions, and observations or errors from the memory into a
series of messages ... Adds a number of keywords (such as PLAN, error, etc) to help
the LLM.
"""
messages = self.memory.system_prompt.to_messages(summary_mode=summary_mode)
for memory_step in self.memory.steps:
messages.extend(memory_step.to_messages(summary_mode=summary_mode))
return messages
Read it slowly. It takes the system prompt, then walks memory.steps in order, asking each step to render itself into chat messages, and concatenates the lot. That list is the prompt sent to the model on the next step. It injects keywords (Observation:, Error:, “Now proceed and carry out this plan.”) so the model knows what it is looking at.
This is why the amnesia bill from the hook happens: an old ActionStep whose observations holds a 50-row DataFrame dump renders that dump into a message every single time write_memory_to_messages() runs. And it is why editing memory.steps works: change the list, and the very next call sees the changed version. The list is the source of truth, and it is mutable.
flowchart LR
SP[SystemPromptStep]:::sys
TS[TaskStep<br/>your question]:::task
A1[ActionStep #1<br/>code + observation]:::action
A2[ActionStep #2<br/>code + observation]:::action
A3[ActionStep #3<br/>final_answer]:::action
FA[FinalAnswerStep<br/>output — NOT in memory.steps]:::final
SP --> TS --> A1 --> A2 --> A3 -.yields.-> FA
SP & TS & A1 & A2 & A3 -->|write_memory_to_messages| MSG[list of ChatMessages]
MSG --> M((Model)):::model
M -->|next step| A2
classDef sys fill:#e8eaf6,stroke:#3949ab
classDef task fill:#e0f2f1,stroke:#00897b
classDef action fill:#fff3e0,stroke:#fb8c00
classDef final fill:#fce4ec,stroke:#d81b60
classDef model fill:#ede7f6,stroke:#5e35b1
⚠️ Common misconception: “Memory is a separate state object I configure.” No. In smolagents, memory is a list of steps and nothing else — no hidden state dict, no schema. That is a deliberate design: it keeps the mental model tiny (read the list, edit the list) and it is what makes context engineering a one-liner instead of a framework. If you came from a graph framework, unlearn the state object now.
Anatomy of an ActionStep (and the signals that aren’t steps)
The ActionStep is where the loop’s work lives, so it has the most fields. Here is the full dataclass from memory.py@v1.26.0:
@dataclass
class ActionStep(MemoryStep):
step_number: int
timing: Timing
model_input_messages: list[ChatMessage] | None = None
tool_calls: list[ToolCall] | None = None
error: AgentError | None = None
model_output_message: ChatMessage | None = None
model_output: str | list[dict[str, Any]] | None = None
code_action: str | None = None
observations: str | None = None
observations_images: list["PIL.Image.Image"] | None = None
action_output: Any = None
token_usage: TokenUsage | None = None
is_final_answer: bool = False
What each field is for, in loop terms:
step_number— 1-based index of this step. (It restarts at 1 on a fresh run; withreset=False, a new run’s steps start at 1 again while the old steps keep their old numbers — important for pruning math, as you’ll see.)timing— aTimingobject carrying the step’s durations. From the source it holdsstart_time,end_time, and adurationproperty (do not invent other fields).model_input_messages— the exact prompt the model saw this step. This is the big oneget_succinct_steps()drops.model_output— the raw “Thought + code” text the model produced.code_action— the Python that was parsed out and executed (this is whatreturn_full_code()concatenates).observations— the captured output of the executed code’sprint()calls, re-shown to the model asObservation:. This is the field aCodeAgentbloats with DataFrame dumps, and the field we prune.observations_images— captured images (browser screenshots, Module 11). We leave it alone here, but it is the exact field the canonical vision pruning callback nulls out — our pruning pattern generalizes it.error— anAgentErrorif the code raised. The agent re-shows it to the model so it can self-correct (the full error hierarchy is Module 8; here it is just a field you can read).token_usage— aTokenUsage(input_tokens,output_tokens,total_tokens). This is what we log for per-step cost. It can legitimately beNone.is_final_answer—Trueon the step that calledfinal_answer.
Internal signals vs public steps (the must-know distinction)
While a step runs, smolagents passes around small dataclasses that are not memory steps. They live in agents.py, not memory.py:
# agents.py — internal signals, yielded by _step_stream
@dataclass
class ActionOutput:
output: Any
is_final_answer: bool
@dataclass
class ToolOutput:
id: str
output: Any
is_final_answer: bool
observation: str
tool_call: ToolCall
ActionOutput is what a CodeAgent’s inner generator yields when its code produces a result; ToolOutput is the ToolCallingAgent analogue. The loop inspects output.is_final_answer to decide whether to stop. These are plumbing — you never store them, and you never see them from outside a run.
⚠️ Common misconception: “
ActionOutputandFinalAnswerStepare the same final-answer object.” They are not, and confusing them makes you write code that doesn’t exist.ActionOutputis an internal signal the loop checks (is_final_answer=True→ stop).FinalAnswerStep(output=...)is the public terminal item thatrun(stream=True)yields at the very end. If you iterate a streamed run looking for the answer, you wait for aFinalAnswerStep— never anActionOutput. (AToolCallingAgentreaches its end throughToolOutput, the same way.)
Reading and mutating memory
Because memory.steps is just a list of these dataclasses, inspecting a run is plain Python — filter for the type you care about and read its fields:
from smolagents import ActionStep
for step in agent.memory.steps:
if isinstance(step, ActionStep):
print(
f"step {step.step_number}: "
f"error={type(step.error).__name__ if step.error else None} | "
f"tokens={step.token_usage} | "
f"obs_len={len(step.observations or '')}"
)
step 1: error=None | tokens=TokenUsage(input_tokens=2143, output_tokens=98, total_tokens=2241) | obs_len=1873
step 2: error=None | tokens=TokenUsage(input_tokens=2562, output_tokens=74, total_tokens=2636) | obs_len=41
step 3: error=None | tokens=TokenUsage(input_tokens=2698, output_tokens=53, total_tokens=2751) | obs_len=0
And because it is a list, you can write to it too. That is the entire lever this module is built on. Here is the at-a-glance table of the five types:
| Step type | What it holds | When it’s created | In memory.steps? | Who reads it |
|---|---|---|---|---|
SystemPromptStep | system_prompt: str | once, at agent build / each run() | No — on memory.system_prompt | write_memory_to_messages (first message) |
TaskStep | task, task_images | start of each run() | Yes | the model (your question) |
ActionStep | code, observation, error, tokens | every loop iteration | Yes | the model + your callbacks |
PlanningStep | plan | every planning_interval steps (M7) | Yes (when planning on) | the model |
FinalAnswerStep | output: Any | once, at the end | No — a stream item only | the caller of run(stream=True) |
Continuing a conversation: reset=False and context engineering
Now the fix for the first half of the hook. run() takes a reset flag, defaulting to True. From the source, here is what it does at the top of every run:
if reset:
self.memory.reset() # clear steps, keep the system prompt
self.monitor.reset() # zero the token counters
self.memory.steps.append(TaskStep(task=self.task, task_images=images))
So run(task) — the default — wipes memory, then starts fresh. That is the right behavior for a one-shot question. But for a follow-up, you want reset=False: it skips the wipe, so the previous run’s steps are still in memory.steps. The model sees the loaded DataFrame, the code it already wrote, and its prior findings, and continues the conversation instead of restarting it.
with build_quill() as agent:
agent.run(build_task("data/sales.csv", "Which category grew fastest?"))
# reset=False keeps the loaded DataFrame and prior findings in memory:
answer = agent.run(
build_task("data/sales.csv", "Now exclude any rows from 2020."),
reset=False,
)
On turn 2 Quill does not reload or re-profile — it already has df in scope and the schema in memory. This is exactly what GradioUI does under the hood for a chat (it calls agent.run(user_request, reset=False)); the UI itself is Module 13.
| memory before run | what the model sees | typical use | gotcha | |
|---|---|---|---|---|
reset=True (default) | wiped | only this task | one-shot question | ”it forgot everything” surprises chat builders |
reset=False | kept | prior steps + this task | a follow-up turn / chat | nothing is saved to disk — RAM only |
⚠️ Common misconception: “
reset=Falsesaves the conversation.” It does not save anything. The memory lives in RAM, inside the agent object. Kill the process and it is gone. Persisting memory to disk or the Hub is Module 13.reset=Falseis continuation within one process, nothing more.
Context engineering: rewriting history to control cost
Here is the second half of the hook, and the heart of the module. Because memory.steps is a mutable list and write_memory_to_messages() re-reads it every step, you can rewrite history. Delete a stale observation and the model never sees it again. Truncate a giant dump and you stop paying for it.
Why this matters in money terms: a CodeAgent that does print(df.describe()) on step 1 re-sends that block on steps 2, 3, 4, and so on. On a long reset=False session, your prompt grows without bound. The Hugging Face Inference Providers free tier is roughly $0.10/month as of smolagents 1.26.0 (subject to change), so a chatty agent burns through it fast — and on a paid provider, you are paying for data the model already summarized into its reasoning two steps ago.
The tool for rewriting history is step_callbacks.
step_callbacks: the (memory_step, agent) hook
A step callback is a function smolagents runs after each step. The frozen signature is (memory_step, agent) — and getting it right matters, because the wrong shape silently never fires. You pass callbacks to the agent two ways:
- a list — each callback is registered against
ActionStepand runs after every action step; - a dict by step type —
{ActionStep: [fn1, fn2]}registers callbacks per type, so they only fire for that type.
Here is how the agent wires them, from agents.py:
def _setup_step_callbacks(self, step_callbacks):
self.step_callbacks = CallbackRegistry()
if step_callbacks:
if isinstance(step_callbacks, list):
for callback in step_callbacks:
self.step_callbacks.register(ActionStep, callback)
elif isinstance(step_callbacks, dict):
for step_cls, callbacks in step_callbacks.items():
...
self.step_callbacks.register(step_cls, callback)
And here is the part that makes mutation possible — how CallbackRegistry actually calls your function:
def callback(self, memory_step, **kwargs):
for cls in memory_step.__class__.__mro__:
for cb in self._callbacks.get(cls, []):
cb(memory_step) if len(inspect.signature(cb).parameters) == 1 else cb(memory_step, **kwargs)
Two things to read off this. First, a 1-argument callback gets only the step; a 2-argument callback gets cb(memory_step, agent=self). That agent is the whole point: it gives your callback agent.memory.steps to read and mutate. Second, the call happens inside _finalize_step(), which runs after each step is built:
def _finalize_step(self, memory_step):
if not isinstance(memory_step, FinalAnswerStep):
memory_step.timing.end_time = time.time()
self.step_callbacks.callback(memory_step, agent=self)
So the sequence each step is: the step finishes → _finalize_step fires your callbacks → the step is appended to memory → the loop continues. Your callback runs before the next write_memory_to_messages(), which is exactly when you want to have pruned.
The pruning pattern
The lab’s pruning callback is the canonical smolagents pattern, generalized from the vision browser’s save_screenshot callback (which nulls out old observations_images so old screenshots stop costing tokens). We do the same for big text observations. Here is the real prune_old_observations from quill/callbacks.py:
from smolagents import ActionStep
KEEP_LAST = 2 # keep the last 2 steps verbatim
MAX_OBS_CHARS = 1000 # only prune observations bigger than this
PRUNE_MARKER = "[pruned: large DataFrame dump removed to save tokens]"
def prune_old_observations(memory_step: ActionStep, agent) -> None:
if not isinstance(memory_step, ActionStep):
return
current = memory_step.step_number
for step in agent.memory.steps: # the just-finished step is NOT in here yet
if not isinstance(step, ActionStep) or step is memory_step:
continue
if current - step.step_number < KEEP_LAST:
continue # too recent — the model still needs it
obs = step.observations
if obs is not None and len(obs) > MAX_OBS_CHARS and obs != PRUNE_MARKER:
step.observations = PRUNE_MARKER # mutate memory in place
It walks the prior steps, finds any ActionStep more than KEEP_LAST steps behind the current one whose observation is bigger than MAX_OBS_CHARS, and replaces the body with a small marker. The next write_memory_to_messages() sends the marker, not the multi-kB dump — that is where the token saving actually happens. The obs != PRUNE_MARKER guard makes it idempotent (running twice does nothing the second time).
The second callback is even simpler — it just makes cost visible per step:
def log_step_cost(memory_step: ActionStep, agent) -> None:
if not isinstance(memory_step, ActionStep):
return
usage = memory_step.token_usage
if usage is None: # legit None: offline model / pre-model error
return # no silent try/except — test `is not None`
print(f"[Quill] step {memory_step.step_number}: "
f"{usage.input_tokens}+{usage.output_tokens} tokens (total {usage.total_tokens})")
Note the explicit is None test instead of a bare try/except: token_usage is genuinely None for an offline model or a step that errored before the model replied, and swallowing an AttributeError would hide real bugs. The factory ties them together — prune first, then log, so the logged step reflects what memory will actually serialize:
def quill_callbacks() -> list[Callable]:
return [prune_old_observations, log_step_cost]
You will reuse this exact file in Module 11, where a screenshot callback prunes observations_images the same way.
Inspecting and streaming a run
You have two reasons to look inside a run: debugging (“why did it do that?”) and observability (“what did it cost?”). smolagents gives you in-process tools for both — no backend, no telemetry stack.
Replay and visualize
agent.replay() prints a pretty, ordered walk of the trajectory: the system prompt, the task, each step’s output. Pass detailed=True to also dump the full model_input_messages at each step (the docstring warns this grows log length exponentially — debugging only). agent.visualize() prints a rich tree of the agent’s structure — its tools and any sub-agents.
agent.replay() # pretty trajectory of what happened
agent.visualize() # rich tree of the agent's tools/structure
print(agent.memory.return_full_code()) # every line of Python it ran
| You want | Use | Notes |
|---|---|---|
| The list of step objects | agent.memory.steps | live dataclasses you can read and mutate |
| A pretty replay of the run | agent.replay() | detailed=True adds the full prompts (debug only) |
| The agent’s structure as a tree | agent.visualize() | tools and sub-agents |
| Steps as dicts | agent.memory.get_succinct_steps() / get_full_steps() | succinct drops model_input_messages |
| Every line of Python it ran | agent.memory.return_full_code() | concatenated code_actions |
| Per-step token cost | step.token_usage on each ActionStep | aggregate via agent.monitor.get_total_token_counts() |
⚠️
agent.logsis dead. It was removed in smolagents 1.21.0. Useagent.memory.stepsandagent.replay(). A huge fraction of tutorials — and some of the official examples — still showagent.logs; do not copy them. If you see.logsin a snippet, the snippet is stale.
One more inspection surface: run(..., return_full_result=True) returns a RunResult with output, state, token_usage, timing, and steps. Watch out: RunResult.steps is a list[dict] (serialized steps), not the live objects. When you want the objects — to read or mutate — go to agent.memory.steps.
verbosity_level: observability by eyeball
Before any telemetry, the cheapest observability is the agent’s own logging. CodeAgent(verbosity_level=...) takes a LogLevel, defaulting to LogLevel.INFO. The full enum from monitoring.py@v1.26.0:
class LogLevel(IntEnum):
OFF = -1 # No output
ERROR = 0 # Only errors
INFO = 1 # Normal output (default)
DEBUG = 2 # Detailed output
Crank it to DEBUG while you are figuring out a misbehaving agent; drop it to ERROR (or OFF) in a server. And remember every step carries its own timing, so per-step duration is already recorded for you. When you graduate from eyeballing to dashboards, that is OpenTelemetry — Module 14.
Streaming: two different things
“Streaming” in smolagents means two distinct things, and people conflate them constantly.
Step-level streaming — run(task, stream=True) returns a generator that yields each step as it completes. You iterate it to watch the run unfold. The yield order, straight from _run_stream: an optional PlanningStep (if planning is on) → one ActionStep per step → finally a single FinalAnswerStep. (If token streaming is also on, ChatMessageStreamDelta items are interleaved.)
Token-level streaming — stream_outputs=True (an argument to CodeAgent/ToolCallingAgent, default False) makes the model stream token deltas as ChatMessageStreamDelta objects, aggregated into the full ChatMessage. This is the live-typing effect.
So stream=True is about steps; stream_outputs=True is about tokens. They are independent flags. Feeding this stream into a Gradio chat (stream_to_gradio) is Module 13 — no UI code here.
from smolagents import FinalAnswerStep
for item in agent.run(build_task("data/sales.csv", "Top category?"), stream=True):
print(type(item).__name__)
if isinstance(item, FinalAnswerStep): # the public terminal item — NOT ActionOutput
print("answer:", item.output)
ActionStep
ActionStep
FinalAnswerStep
answer: Team has the highest net revenue
Under the hood: the internal generators
stream=True is not a special mode — it is the native shape of the loop, and the non-streaming run() just drains it. Three generators do the work:
_run_stream()— the outer generator. It runs thewhileloop, schedules planning steps, enforcesmax_steps, builds eachActionStep, and yields steps. From the source, the non-streaming branch ofrun()is literallysteps = list(self._run_stream(...))— it just exhausts the generator._step_stream()— an abstract generator each agent subclass implements. Its annotated yield type isChatMessageStreamDelta | ToolCall | ToolOutput | ActionOutput— exactly the internal signals from earlier. This is “one step in the ReAct framework: the agent thinks, acts, and observes.”step()— a synchronous wrapper:return list(self._step_stream(memory_step))[-1]. It drains one step’s generator and hands back the last item.
You almost never call these directly, but knowing they exist tells you precisely what a “step” is: one drain of _step_stream. If you ever need to drive the loop one step at a time — for a custom harness or a teaching demo — the pattern is to seed the task, append a TaskStep, and call agent.step(...) in a loop. That manual loop is what run() automates.
flowchart TB
subgraph public["Public stream — what run(stream=True) yields"]
direction LR
P[PlanningStep?]:::plan --> AS1[ActionStep]:::act --> AS2[ActionStep]:::act --> FAS[FinalAnswerStep]:::fin
end
subgraph internal["Internal generator stack"]
direction TB
R["run()"] --> RS["_run_stream() — loop, planning, max_steps"]
RS --> SS["_step_stream() — yields ChatMessageStreamDelta / ToolCall / ToolOutput / ActionOutput"]
SS --> ST["step() = list(_step_stream(...))[-1]"]
end
internal -. produces .-> public
classDef plan fill:#e8eaf6,stroke:#3949ab
classDef act fill:#fff3e0,stroke:#fb8c00
classDef fin fill:#fce4ec,stroke:#d81b60
Build it
Goal: make Quill multi-turn with run(..., reset=False) and add quill/callbacks.py — two step callbacks that prune stale DataFrame dumps and log per-step cost — then replay the trajectory to see exactly what was kept. The full, tested code is in smolagents-course-labs/module-06; every offline test passes with no token and no network.
Observable result: two questions on one agent, the second reset=False. Turn 2 reuses the already-loaded DataFrame, each step prints a step N: <in>+<out> tokens line, and the old big observation reads [pruned: ...] in the replay.
1. Setup. Copy the Module 5 state forward (the cumulative rule: Module 6 code must still pass the Module 1–6 smoke tests). No new dependency — step_callbacks and reset=False are core smolagents, installed since Module 3/5.
uv venv --python 3.11
uv pip install "smolagents[toolkit,litellm,openai,docker]==1.26.0" \
"huggingface_hub>=1.0,<2" "pandas>=2.2.3" matplotlib
cp module-06/.env.example module-06/.env # then add your HF token; never commit .env
data/sales.csv is inherited from Module 2 unchanged.
2. Read the memory first. Before writing any callback, look at what you are working with — walk memory.steps, filter the ActionSteps, and call agent.replay(). (Always agent.memory.steps / agent.replay(), never agent.logs.)
from smolagents import ActionStep
from quill.agent import build_quill, build_task
with build_quill() as agent:
agent.run(build_task("data/sales.csv", "Which category has the highest net_rev?"))
for step in agent.memory.steps:
if isinstance(step, ActionStep):
obs_len = len(step.observations or "")
print(f"step {step.step_number}: tokens={step.token_usage} | observations={obs_len} chars")
agent.replay()
print(agent.memory.return_full_code())
3. Write quill/callbacks.py. Two functions with the frozen (memory_step, agent) signature — prune_old_observations and log_step_cost — plus the quill_callbacks() factory. (The exact bodies are in the sections above; the file’s module docstring spells out the internals.)
4. Wire them into build_quill() — one line, and nothing else changes. The model factory, tool signatures, and sandbox policy are all untouched:
from .callbacks import quill_callbacks
def build_quill(model: Model | None = None) -> CodeAgent:
executor_type, authorized_imports = resolve_executor()
return CodeAgent(
tools=[load_dataset, profile_dataframe, save_chart(),
WebSearchTool(), VisitWebpageTool()],
model=model or make_model(role="analyst"),
executor_type=executor_type,
additional_authorized_imports=authorized_imports,
step_callbacks=quill_callbacks(), # NEW (M6): prune + log, after every step
max_steps=8,
)
5. Go multi-turn. The repo ships run_multi_turn(csv, q1, q2) and the --multi-turn CLI flag. It runs turn 1 (default reset=True), then turn 2 (reset=False) on the same agent, and prints how memory grew.
uv run python -m quill.agent --multi-turn data/sales.csv \
"Which category grew fastest from Q1 to Q4 2025?" \
"Now answer the same question but exclude any rows from 2020."
===== TURN 1 (reset=True, the default) =====
[Quill] step 1: 2143+98 tokens (total 2241)
[Quill] step 2: 2562+74 tokens (total 2636)
...
Turn 1 answer: Team grew fastest ...
===== TURN 2 (reset=False — keep turn 1 in memory) =====
[Quill] step 1: 3110+61 tokens (total 3171) # the loaded df is still there — no reload
...
Turn 2 answer: Excluding 2020, Team still ...
===== MEMORY GREW (reset=False did not wipe turn 1) =====
steps after turn 1 : 3
steps after turn 2 : 5 (turn 1's steps are still here)
===== FULL TRAJECTORY (agent.replay) =====
... # an old ActionStep observation: [pruned: large DataFrame dump removed to save tokens]
6. Inspect the effect. Re-walk memory.steps and confirm the old big observations are now the marker; compare total context size before vs after:
total = sum(len(s.observations or "") for s in agent.memory.steps if isinstance(s, ActionStep))
print(f"total observation chars now in memory: {total}") # far smaller than the raw dumps
7. Tests. The callback tests are offline and spend zero tokens — they build an ActionStep by hand and call the callback directly. That is the whole point of the module: you verify pruning and cost logging without an LLM.
uv run pytest module-06/tests/ # offline: no token, no Docker
QUILL_LIVE_TESTS=1 uv run pytest module-06/tests/ # also the real multi-turn LLM run
The offline suite proves the headline behaviors: prune_old_observations prunes a big observation on a step older than KEEP_LAST, leaves recent/small ones alone, ignores non-ActionStep steps, and is idempotent; log_step_cost does not raise when token_usage is None; build_quill() wires the callbacks so they fire in a real (fake-model) run and prune the old dump in agent.memory.steps; and reset=False continues memory while the default reset=True wipes it. Every Module 2/3/4/5 test still passes.
Try it yourself (not graded):
- Use the dict-by-type form. Pass
step_callbacks={ActionStep: [prune_old_observations, log_step_cost]}and confirm nothing fires on aPlanningStep(you’ll see planning steps once you enableplanning_intervalin Module 7). - Summarize instead of delete. Add a third callback that replaces an old big observation with a one-line heuristic summary (its first + last line) instead of the marker, and compare how much context you keep vs. how much you save.
In production
In the lab, memory is a few steps and the bill is rounding error. In production, a multi-turn agent’s memory grows without bound, and a CodeAgent that prints DataFrames is the worst case. Two failures wait at the end of a long session: you blow past the model’s context window, or your token bill quietly compounds because write_memory_to_messages() re-sends every old observation on every step.
So here is the opinion I will defend with numbers: in production, I cap what any single observation can return and I null out big dumps older than two steps. Beyond two steps the model has almost always folded the data into its own reasoning, and after that you are paying — in latency and tokens — for bytes nobody reads. KEEP_LAST = 2 is not arbitrary; it is the smallest window that keeps the model’s immediate working set intact while killing the long tail.
The deeper point: deciding what to keep, what to prune, and what to summarize is the real job of context engineering, and write_memory_to_messages() is the exact place that decision lands. A step callback is your hook into it. The log_step_cost line is the minimal observability brick — cost in plain sight, per step, before you reach for a real backend. When you outgrow replay() and per-step prints and want dashboards, traces, and aggregate cost, that is OpenTelemetry — Module 14.
Concept check
Why this matters: memory in smolagents is not a feature you turn on — it is the substrate the whole loop runs on. If you understand that it is a mutable list of steps that gets re-serialized into a prompt every step, you can debug any run, control any bill, and build any chat. If you don’t, you will fight phantom amnesia and watch costs climb for no visible reason. These five scenarios are the ones that actually bite.
1. You build a chat on top of a CodeAgent. Each user message calls agent.run(message). Users complain the agent “forgets everything between messages.” What is the fix?
- A. Add a
step_callbackthat saves memory to disk. - B. Call
agent.run(message, reset=False)so memory carries forward. - C. Increase
max_steps. - D. Switch to
stream=True.
2. Your CodeAgent profiles a big DataFrame on step 1 with print(df.describe()). By step 6 latency and token cost have ballooned. What is the cause, and the right mitigation?
- A. The model is slow; switch providers.
- B.
max_stepsis too high; lower it. - C. The step-1 observation is re-sent to the model every step via
write_memory_to_messages(); add astep_callbackthat prunes old observations. - D. The sandbox is leaking memory; restart the container.
3. Which signature does smolagents call your step callback with?
- A.
def cb(agent, memory_step): - B.
def cb(memory_step):only - C.
def cb(memory_step, agent): - D.
def cb(state, memory_step, agent):
4. You iterate a streamed run, for item in agent.run(task, stream=True):, and want to catch the final answer. Which object do you wait for?
- A.
ActionOutput - B.
FinalAnswerStep - C.
ToolCall - D.
RunResult
5. You want to know exactly what Python the agent ran and what each step cost. Which combination is correct — and which option is stale?
- A.
agent.logsfor both. - B.
agent.memory.return_full_code()for the code,ActionStep.token_usagefor cost;agent.logsis stale. - C.
agent.replay(detailed=True)for cost,agent.logsfor code. - D.
RunResult.stepsobjects’.code_action, mutated in place.
Answers.
1 — B. reset=True is the default and wipes memory before every run, so each message starts amnesiac. reset=False keeps the prior steps in memory.steps, which is exactly what a chat needs (and what GradioUI does under the hood). A is wrong scope (persistence is Module 13, and it is not what “forgets between messages” needs); C and D are unrelated.
2 — C. Every step, write_memory_to_messages() walks memory.steps and renders each one — including the fat step-1 observation — back into the prompt. So you re-pay for that dump on steps 2 through 6. The fix is a step_callback (signature (memory_step, agent)) that nulls out stale observations, so the next serialization sends a small marker instead.
3 — C. The frozen signature is (memory_step, agent). CallbackRegistry calls a 2-argument callback as cb(memory_step, agent=agent). A is inverted (it would bind the step to agent and the agent to memory_step); B fires but can never read or mutate memory; D has an argument that doesn’t exist.
4 — B. run(stream=True) yields steps, and the terminal one is a FinalAnswerStep whose .output is the answer. ActionOutput is an internal signal yielded by _step_stream and inspected by the loop — you never catch it from outside. ToolCall is mid-step plumbing; RunResult is what return_full_result=True returns, not a stream item.
5 — B. agent.memory.return_full_code() concatenates every code_action into one script, and each ActionStep.token_usage carries that step’s cost. agent.logs was removed in 1.21.0 — it is the stale option in every list it appears in. D fails because RunResult.steps are dicts, not live objects, so there is nothing to mutate.
Common pitfalls:
agent.logsis gone. Removed in 1.21.0. Useagent.memory.steps/agent.replay(). This is the single most common stale snippet in smolagents tutorials — if you copy.logs, your code breaks.reset=Falsesaves nothing. Memory is in RAM, in the agent object. Restart the process and it is gone. Disk/Hub persistence is Module 13.ActionOutput≠FinalAnswerStep. One is an internal signal inagents.py(_step_streamyields it, the loop checksis_final_answer); the other is the public terminal stream item inmemory.py. Never write external code that catchesActionOutput.
Key takeaways
- Memory in smolagents is a
system_promptplus a list of typed steps (agent.memory.steps) — there is no separate state object. write_memory_to_messages()re-serializes that list into the model’s prompt every step, which is why old observations keep costing tokens and why editing the list works.reset=Falsecontinues a conversation across runs;reset=True(the default) wipes memory first. Neither persists to disk — that is RAM-only.- A
step_callbackwith signature(memory_step, agent)can read and mutateagent.memory.stepsto prune stale dumps and log per-step cost; it fires in_finalize_stepvia aCallbackRegistry. - Inspect runs with
agent.memory.steps,agent.replay(),return_full_code(), andActionStep.token_usage— neveragent.logs(removed in 1.21.0). ActionOutput(internal signal,agents.py) is notFinalAnswerStep(public stream item,memory.py);stream=Trueyields steps,stream_outputs=Truestreams tokens.
Keep going
Want the full smolagents concept-check bank and the next module in your inbox? Subscribe here — it’s free, like everything in this series.
Quill remembers now — but it still wanders. Module 7 gives it a plan: planning_interval, sharper instructions, and fewer wasted steps.
Previous: Module 5 — Running Untrusted Code Safely: Sandboxing smolagents · Next: Module 7 — Planning and Building Good Agents · Course index · Lab code for this module
References
- smolagents docs — Manage your agent’s memory (read, replay, and
step_callbacks): huggingface.co/docs/smolagents/tutorials/memory - smolagents docs — How do multi-step agents work? (the ReAct loop that produces steps): huggingface.co/docs/smolagents/conceptual_guides/react
- smolagents API reference —
AgentMemory,replay,visualize,step_callbacks: huggingface.co/docs/smolagents/reference/agents - smolagents source at the
v1.26.0tag —src/smolagents/memory.py(step types,AgentMemory,CallbackRegistry) andsrc/smolagents/agents.py(run,_run_stream,_step_stream,step,ActionOutput): github.com/huggingface/smolagents/tree/v1.26.0 - Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models (arXiv 2210.03629) — the loop whose iterations become
ActionSteps: arxiv.org/abs/2210.03629
Verified against smolagents 1.26.0 (latest at build time).