Give Your Agent a Memory: State, Steps, and Inspecting Runs (smolagents, Module 6)

Module 6 of 15 13 min read Lab code ↗

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: AgentMemory is a system_prompt plus a list of typed steps, and write_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(), and agent.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=True yields steps, stream_outputs=True yields 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, the make_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-turnreset=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() — clears steps, keeps the system prompt. This is what run(reset=True) calls.
  • get_succinct_steps() — every step as a dict, minus the model_input_messages field (the bulky raw prompt the model saw). Use this when you want a readable trace.
  • get_full_steps() — the same, with model_input_messages. Heavy, but complete.
  • return_full_code() — concatenates every code_action from every ActionStep into one script. For a CodeAgent, 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:

  • SystemPromptStepsystem_prompt: str. The instructions. Stored on memory.system_prompt, not in steps.
  • TaskSteptask: str, task_images: list[PIL.Image.Image] | None. The user’s question (plus any input images — vision input is Module 11; here task_images stays None).
  • ActionStep — the core step of the loop. Dissected below.
  • PlanningStepplan: str plus model_input_messages, model_output_message, timing, token_usage. A periodic planning step. It appears in steps only when planning is enabled — we just name the type here; configuring planning_interval is Module 7.
  • FinalAnswerStepoutput: Any. The terminal item. This one is special: it is the last thing run(stream=True) yields, but it is not appended to memory.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; with reset=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 — a Timing object carrying the step’s durations. From the source it holds start_time, end_time, and a duration property (do not invent other fields).
  • model_input_messages — the exact prompt the model saw this step. This is the big one get_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 what return_full_code() concatenates).
  • observations — the captured output of the executed code’s print() calls, re-shown to the model as Observation:. This is the field a CodeAgent bloats 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 — an AgentError if 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 — a TokenUsage (input_tokens, output_tokens, total_tokens). This is what we log for per-step cost. It can legitimately be None.
  • is_final_answerTrue on the step that called final_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:ActionOutput and FinalAnswerStep are the same final-answer object.” They are not, and confusing them makes you write code that doesn’t exist. ActionOutput is an internal signal the loop checks (is_final_answer=True → stop). FinalAnswerStep(output=...) is the public terminal item that run(stream=True) yields at the very end. If you iterate a streamed run looking for the answer, you wait for a FinalAnswerStep — never an ActionOutput. (A ToolCallingAgent reaches its end through ToolOutput, 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 typeWhat it holdsWhen it’s createdIn memory.steps?Who reads it
SystemPromptStepsystem_prompt: stronce, at agent build / each run()No — on memory.system_promptwrite_memory_to_messages (first message)
TaskSteptask, task_imagesstart of each run()Yesthe model (your question)
ActionStepcode, observation, error, tokensevery loop iterationYesthe model + your callbacks
PlanningStepplanevery planning_interval steps (M7)Yes (when planning on)the model
FinalAnswerStepoutput: Anyonce, at the endNo — a stream item onlythe 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 runwhat the model seestypical usegotcha
reset=True (default)wipedonly this taskone-shot question”it forgot everything” surprises chat builders
reset=Falsekeptprior steps + this taska follow-up turn / chatnothing is saved to disk — RAM only

⚠️ Common misconception:reset=False saves 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=False is 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 ActionStep and 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 wantUseNotes
The list of step objectsagent.memory.stepslive dataclasses you can read and mutate
A pretty replay of the runagent.replay()detailed=True adds the full prompts (debug only)
The agent’s structure as a treeagent.visualize()tools and sub-agents
Steps as dictsagent.memory.get_succinct_steps() / get_full_steps()succinct drops model_input_messages
Every line of Python it ranagent.memory.return_full_code()concatenated code_actions
Per-step token coststep.token_usage on each ActionStepaggregate via agent.monitor.get_total_token_counts()

⚠️ agent.logs is dead. It was removed in smolagents 1.21.0. Use agent.memory.steps and agent.replay(). A huge fraction of tutorials — and some of the official examples — still show agent.logs; do not copy them. If you see .logs in 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 streamingrun(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 streamingstream_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 the while loop, schedules planning steps, enforces max_steps, builds each ActionStep, and yields steps. From the source, the non-streaming branch of run() is literally steps = list(self._run_stream(...)) — it just exhausts the generator.
  • _step_stream() — an abstract generator each agent subclass implements. Its annotated yield type is ChatMessageStreamDelta | 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):

  1. Use the dict-by-type form. Pass step_callbacks={ActionStep: [prune_old_observations, log_step_cost]} and confirm nothing fires on a PlanningStep (you’ll see planning steps once you enable planning_interval in Module 7).
  2. 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_callback that 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_steps is too high; lower it.
  • C. The step-1 observation is re-sent to the model every step via write_memory_to_messages(); add a step_callback that 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.logs for both.
  • B. agent.memory.return_full_code() for the code, ActionStep.token_usage for cost; agent.logs is stale.
  • C. agent.replay(detailed=True) for cost, agent.logs for code.
  • D. RunResult.steps objects’ .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.logs is gone. Removed in 1.21.0. Use agent.memory.steps / agent.replay(). This is the single most common stale snippet in smolagents tutorials — if you copy .logs, your code breaks.
  • reset=False saves nothing. Memory is in RAM, in the agent object. Restart the process and it is gone. Disk/Hub persistence is Module 13.
  • ActionOutputFinalAnswerStep. One is an internal signal in agents.py (_step_stream yields it, the loop checks is_final_answer); the other is the public terminal stream item in memory.py. Never write external code that catches ActionOutput.

Key takeaways

  • Memory in smolagents is a system_prompt plus 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=False continues a conversation across runs; reset=True (the default) wipes memory first. Neither persists to disk — that is RAM-only.
  • A step_callback with signature (memory_step, agent) can read and mutate agent.memory.steps to prune stale dumps and log per-step cost; it fires in _finalize_step via a CallbackRegistry.
  • Inspect runs with agent.memory.steps, agent.replay(), return_full_code(), and ActionStep.token_usage — never agent.logs (removed in 1.21.0).
  • ActionOutput (internal signal, agents.py) is not FinalAnswerStep (public stream item, memory.py); stream=True yields steps, stream_outputs=True streams 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

Verified against smolagents 1.26.0 (latest at build time).