Your First CodeAgent: The ReAct Loop, Step by Step (smolagents, Module 2)

Module 2 of 15 13 min read Lab code ↗

This is Module 2 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 have a CSV of monthly sales and a question: which product category grew fastest last year? You already know how you’d answer it — load it with pandas, group by category, compute a growth ratio, read off the winner. Ten minutes of code.

In Module 1 we said a CodeAgent writes its actions as Python instead of JSON — the code-as-action idea. But that stayed an abstraction. You read about it; you never watched it happen. So this module does the one thing that makes it click: we hand that CSV and that question to an agent and watch it write the pandas for us — one step at a time. Better still, we watch it misspell a column name, hit a KeyError, read its own error, and fix itself on the very next step without a single human touch.

By the end you’ll know exactly what happens when a CodeAgent runs — Thought, Code, Observation — what one step really is, and how the loop ends.

In this module

You’ll learn

  • Trace the ReAct loop inside a smolagents CodeAgent — Thought → Code → Observation — and define precisely what one step is.
  • Run an agent with agent.run(...) and read its full trajectory with agent.replay() and agent.memory.steps.
  • Explain how an agent terminates: the FinalAnswerTool, the is_final_answer signal, and what RunResult gives you.
  • Configure the two knobs that bound and empower a CodeAgent at this stage — max_steps and additional_authorized_imports.
  • Recognize how an agent self-corrects: an error becomes ActionStep.error, is fed back to the model, and the next step fixes it.

You’ll build

Quill v0 — a CodeAgent that, given a CSV and a question, writes and runs pandas to answer it, then calls final_answer. You’ll read exactly how it got there with agent.replay().

Theory areas covered

  • T1 — The ReAct loop & agent classes — 13% (the single heaviest area of the course; shared with Module 1).

Prerequisites

  • Module 1 — the agency spectrum, code-as-action, and what a CodeAgent is.
  • Python 3.11 or 3.12.
  • An HF_TOKEN in your .env (you set this up in Module 1).
  • pip install 'smolagents[toolkit]==1.26.0' pandas (or use uv, shown in the lab).

Where we are

Quill is the data-analyst agent you build across the whole course, one capability per module.

  • M1 ✅ — what code agents are; a throwaway CodeAgent that does a calculation.
  • 👉 M2 — you are here — Quill v0 is born.
  • M3–M15 ⬜ — tools, models, sandboxing, memory, planning, structured output, MCP, multi-agents, vision, RAG, UI, telemetry, capstone.

Quill before this module: nothing — Module 1’s agent was a disposable demo, not yet “Quill.” Quill after this module: Quill v0 — hand it data/sales.csv and a question, and it writes pandas to answer.

The agent loop: Thought, Code, Observation

In Module 1 you saw the premise: a code agent writes its actions in Python. Now let’s watch the machine that makes that a loop.

Every agent in smolagents descends from one base class: MultiStepAgent. It implements the ReAct loop — short for Reason + Act, from Yao et al., 2022 (arXiv 2210.03629). The official docs put it plainly:

While the objective is not reached, the agent will perform a cycle of action (given by the LLM) and observation (obtained from the environment).

MultiStepAgent is effectively abstract — you never instantiate it directly. (initialize_system_prompt and the per-step logic are “to be implemented in child classes.”) You can confirm this against the installed library:

import inspect
from smolagents import MultiStepAgent

print(inspect.isabstract(MultiStepAgent))
True

Two concrete subclasses fill in the action format:

  • CodeAgent — the default. Its actions are Python code, parsed and then executed. This is the class Quill is built on, and the only one we dig into here.
  • ToolCallingAgent — the other subclass; its actions are JSON tool calls. One line is all it gets in this module; Module 3 compares the two properly.

For a CodeAgent, one trip around the loop has three named beats, straight out of the default system prompt:

  1. Thought: — the model reasons in plain language about what to do next.
  2. A block of Code — the Python it wants to run, wrapped in the code_block_tags. As of smolagents 1.26.0 those tags default to <code></code> (this changed recently; older tutorials show a markdown ```py fence — the detail of these tags is Module 7).
  3. Observation: — what the executed code produced. And here is the piece beginners miss: anything the code print()s becomes the Observation: that the model reads at the start of the next step. The agent literally talks to itself through stdout. A print() is not debug noise; it is the agent’s information channel into its own next turn.

Here is the loop as a diagram. One trip around it is one step.

flowchart TD
  T["Task: CSV + question"] --> TH["Thought: LLM reasons"]
  TH --> C["Code: writes Python"]
  C --> E["Execute in local interpreter"]
  E --> O["Observation: print() output or error"]
  O --> D{"final_answer() called?"}
  D -- "no (loop again)" --> TH
  D -- "yes" --> F["FinalAnswerStep → RunResult"]

  subgraph one_step ["one trip around this loop = 1 step = 1 ActionStep"]
    TH
    C
    E
    O
  end

Under the hood (the why). The loop is a chain of generators in agents.py. run() drives _run_stream, which drives _step_stream, which calls step() — documented as performing “one step in the ReAct framework: the agent thinks, acts, and observes.” Before each model call, write_memory_to_messages() converts the recorded steps back into a list of chat messages and sends them to the model — that is how the previous step’s observation reaches the next Thought. You don’t touch any of these internals here (the full memory machinery is Module 6); the point is that the loop is mechanical and inspectable, not magic.

What exactly is a step?

This is the definition to burn into memory, because the rest of the course stacks on it:

One step = 1 LLM call → 1 parsed action → 1 execution → 1 ActionStep appended to memory.

Not “one tool call.” Not “one line of code.” Not “one model message regardless of execution.” One step is one full Thought-Code-Observation trip, recorded as one entry in the agent’s memory. (A PlanningStep is interleaved separately and doesn’t consume the action budget the same way — planning is Module 7.)

The recorded entry is an ActionStep: the journal of what happened that turn. You’ll read these in the lab. The fields that matter at this stage:

Field on ActionStepWhat it holds
step_numberwhich step this was (1, 2, 3, …)
model_outputthe raw text the LLM produced (Thought + code block)
code_actionthe Python that was extracted from it
observationswhat the code printed (the next step’s Observation:)
erroran AgentError if the step failed, else None
token_usagetokens spent on this step’s LLM call
is_final_answerTrue if this step called final_answer

ActionStep has more fields than these, and other kinds of step exist (TaskStep, PlanningStep, FinalAnswerStep, SystemPromptStep). Module 6 dissects all of them; here we only ever read ActionStep.

The two knobs you set right now

A CodeAgent takes many constructor arguments. At this stage you deliberately tune exactly two.

max_steps — the loop ceiling. Its default is 20 as of smolagents 1.26.0 (a default, not a constant — verify it against your installed wheel rather than memorizing it):

import inspect
from smolagents import MultiStepAgent

print(inspect.signature(MultiStepAgent.__init__).parameters["max_steps"].default)
20

Why it exists: an agent can loop without converging — a vague question, a broken tool, a model that won’t commit to an answer. Without a ceiling it burns tokens forever. Under the hood: when the budget runs out, _handle_max_steps_reached() produces a best-effort fallback answer and the run ends in state "max_steps_error" (not a crash, but not a real answer either). My rule for a single-CSV analysis: cap at 6–8. If Quill needs 20 steps to read one file, something is wrong upstream — the data, the question, or the prompt — and a high ceiling just hides it behind a bigger bill. Quill v0 caps at max_steps=8.

additional_authorized_imports — the allow-list of modules the interpreter may import. The reason this exists is the one beginners trip over: pandas and numpy are not importable by default inside a CodeAgent’s sandbox. If you don’t declare them, the agent’s import pandas fails. So Quill declares them:

additional_authorized_imports=["pandas", "numpy"]

This list is also a security boundary — Module 5 covers the threat model and why you lock it down (and why you never use the "*" wildcard). For now, treat it pragmatically: declare what the sandbox is allowed to import, and keep the list minimal.

Both of these are arguments you pass when constructing the agent. additional_authorized_imports is specific to CodeAgent; max_steps actually comes from MultiStepAgent.__init__ and is threaded through via **kwargs. You don’t need to memorize the full constructor — here are the arguments worth knowing about at this stage, with where each is taught:

CodeAgent / MultiStepAgent argumentDefault (as of 1.26.0)Covered in
tools(required)M3
model(required)M4
additional_authorized_imports[]M2 (+ security in M5)
max_steps20M2
add_base_toolsFalseM3
executor_type"local"M5
planning_intervalNoneM7
instructionsNoneM7
final_answer_checksNoneM8
return_full_resultFalseM2
verbosity_level(info)M6

You set two of these now. The rest light up as Quill grows.

How an agent stops: final_answer and RunResult

You run an agent with agent.run(task, ...). The signature has a few arguments worth knowing today:

run() argumentDefaultMeaning
task(required)the natural-language task string
streamFalseyield steps live instead of returning at the end (Module 6)
resetTrueclear memory before this run; reset=False (continue a conversation) is Module 6
additional_argsNoneextra objects to inject into the run
max_stepsNoneoverride the constructor’s ceiling for this run
return_full_resultFalsereturn a RunResult instead of just the answer

(images= is for vision and is Module 11.)

Now the crucial part: the agent does not “decide” to stop. There is no internal “I’m finished” flag. The agent stops because it calls the final_answer tool.

FinalAnswerTool is a default tool that is always present on every agent, even when you pass tools=[]. You can inspect it directly:

from smolagents import FinalAnswerTool

t = FinalAnswerTool()
print(t.name, "|", t.output_type)
final_answer | any

Its forward() simply returns whatever it’s given. For a CodeAgent, the model calls it like an ordinary Python function inside a code block:

final_answer("Team (highest net revenue)")

Under the hood: the loop watches the result of each executed action for an output flagged is_final_answer=True. When it sees one, it logs "Final answer:", runs _validate_final_answer() (the final_answer_checks — 3-argument validators, deep in Module 8), and breaks the loop. That output flag is the entire termination mechanism. No flag, no stop.

Getting the full picture with RunResult

By default, run() returns just the final-answer payload — whatever you passed to final_answer, as a plain Python value. That’s convenient but blind: you can’t see how many steps it took or what it cost.

Pass return_full_result=True and run() returns a RunResult instead — a dataclass with everything you need to inspect a run programmatically:

from smolagents import RunResult

print([f for f in RunResult.__dataclass_fields__])
['output', 'state', 'steps', 'token_usage', 'timing']
  • output — the final answer (same value run() would return by default).
  • state"success" or "max_steps_error".
  • steps — a list[dict] of serialized steps. Note: these are dicts, not the step objects. For the live objects (to call isinstance(step, ActionStep)), use agent.memory.steps.
  • token_usage — total tokens for the run (None offline, since usage is only meaningful against a real model).
  • timing — wall-clock timing for the run.

Why it exists: so you can audit cost and trajectory without parsing logs. The lab uses it to print state, the step count, and token usage after every run.

⚠️ Common misconception: “the agent decides on its own when it’s done.”

It doesn’t. There is no “I’m finished” flag inside the loop. An agent stops for exactly one of two reasons: it called the final_answer tool, or it hit max_steps. If the model never calls final_answer, the agent loops all the way to the ceiling and finishes in state="max_steps_error" — burning tokens the whole way. This is the number-one mental trap for beginners, and it’s also why a sane max_steps is non-negotiable.

When the agent gets it wrong (and fixes itself)

This is the moment everything has been building toward.

At step n, the model writes df["Catgory"] — a typo. Pandas raises a KeyError. Here is what doesn’t happen: the run does not crash. Here is what does:

  1. The error is captured into ActionStep.error (typed AgentError | None), not raised up the stack.
  2. On the next turn, write_memory_to_messages() feeds that error back to the model as part of the conversation.
  3. At step n+1, the model reads the error, inspects the real columns, writes df["category"], and answers.

The docs frame this exactly right:

Most of the errors are just “LLM dumb” kind of errors, from which the LLM auto-corrects in the next step.

That self-correction loop is the whole pitch of code agents. The model isn’t perfect; it doesn’t have to be. The loop turns mistakes into the next step’s context.

The captured errors belong to a family rooted at AgentError. You don’t need the taxonomy yet — Module 8 owns it — but it helps to see the shape of it. Here’s the hierarchy at intro level:

Error classParentBorn when… (intro — full detail in M8)
AgentErrorExceptionbase class for the whole family
AgentParsingErrorAgentErrorthe model’s output can’t be parsed into an action
AgentExecutionErrorAgentErrorthe action fails while running
AgentGenerationErrorAgentErrorthe model call itself fails
AgentMaxStepsErrorAgentErrorthe step ceiling is reached
AgentToolCallErrorAgentExecutionErrora tool is called with bad arguments
AgentToolExecutionErrorAgentExecutionErrora tool raises while executing

You can confirm the lineage against the installed library:

from smolagents.utils import (
    AgentError, AgentExecutionError, AgentToolExecutionError,
)
print(issubclass(AgentToolExecutionError, AgentExecutionError))
print(issubclass(AgentExecutionError, AgentError))
True
True

The key distinction: a step error is not a run failure. Only running out of max_steps truly ends a run (state="max_steps_error"). Every other error is an opportunity to self-correct.

Reading the trajectory

How do you see all of this? Two tools, and only these two.

agent.replay() — “prints a pretty replay of the agent’s steps.” It’s the number-one inspection tool of this module: run the agent, then call replay() to watch the whole Thought / Code / Observation trajectory, error steps and all.

agent.memory.steps — the live list of step objects, for iterating programmatically. (Never agent.logs — that attribute was removed in smolagents 1.21.0. If a tutorial reads agent.logs, it’s stale.)

The pattern for walking the trajectory yourself:

from smolagents import ActionStep

for step in agent.memory.steps:
    if isinstance(step, ActionStep):
        print(step.step_number, step.error, step.observations)

agent.replay() is for your eyes; agent.memory.steps is for your code. Module 6 goes deep on memory and step inspection (and the advanced visualize()); here they’re just how you read what Quill did.

Build it: Quill v0

Goal: build Quill v0 — a CodeAgent that, given a CSV and a question, writes and runs pandas to answer it, calls final_answer, and whose full trajectory you read with agent.replay(). At the end, running one command prints the agent’s step-by-step trajectory, the final answer, and a RunResult recap. The full code is in module-02/; the tests pass offline.

1. Setup. pandas is not a smolagents dependency, so add it explicitly. Your HF_TOKEN goes in .env (set up in Module 1) — never commit it.

uv venv --python 3.11
uv pip install "smolagents[toolkit]==1.26.0" "huggingface_hub>=1.0,<2" "pandas>=2.2.3"
cp module-02/.env.example module-02/.env   # then add your HF token

2. The data. data/sales.csv is the canonical Quill dataset — inherited unchanged by every later module, never renamed. It holds monthly 2025 SaaS figures:

month,region_code,category,units,net_rev,churn_flag
2025-01,NA,Free,205,0.0,0.062
2025-02,NA,Free,190,0.0,0.094
...

“Which category grew fastest?” is non-trivial on purpose: it needs a group-by plus a growth ratio (compare each category’s first-quarter total to its last-quarter total).

3. quill/agent.pybuild_quill(). This is the canonical Quill construction entry point. It’s deliberately minimal now, and its signature will be extended (never replaced) every later module. Accepting model=None lets tests pass a fake model and leaves a clean seam for Module 4’s make_model():

import os
from smolagents import CodeAgent, InferenceClientModel, Model

DEFAULT_MODEL_ID = "Qwen/Qwen2.5-Coder-32B-Instruct"
QUILL_IMPORTS = ["pandas", "numpy"]

def build_quill(model: Model | None = None) -> CodeAgent:
    return CodeAgent(
        tools=[],  # no custom tools yet — final_answer is always present (M3 adds tools)
        model=model or InferenceClientModel(
            model_id=DEFAULT_MODEL_ID, token=os.environ.get("HF_TOKEN")
        ),
        additional_authorized_imports=QUILL_IMPORTS,
        max_steps=8,
    )

We pass an explicit model_id. InferenceClientModel’s default model is documented as “subject to change” (as of smolagents 1.26.0), and a data/code agent wants a coder/instruct model — so we never rely on the default. (Module 4 turns this into make_model() in quill/config.py; for now we use InferenceClientModel plainly.)

4. Phrase the task and run it. We tell the agent where the CSV is and ask it to load the file itself, then run with the full result so we get a RunResult:

def build_task(csv_path: str, question: str) -> str:
    return (
        f"The CSV is at {csv_path}. Question: {question}\n"
        "Load it with pandas, inspect the columns first, then compute the answer and call "
        "final_answer with a short, specific result."
    )

agent = build_quill()
result = agent.run(build_task(csv_path, question), return_full_result=True)

5. Inspect the trajectory. Read it the supported way — replay() for your eyes, memory.steps for your code:

from smolagents import ActionStep

agent.replay()                       # pretty-prints every step: Thought / Code / Observation
print(result.state, len(result.steps), result.token_usage)
for step in agent.memory.steps:      # NEVER agent.logs (removed in 1.21.0)
    if isinstance(step, ActionStep):
        print(step.step_number, step.error, (step.observations or "")[:80])

Run the whole thing from inside module-02/:

uv run python -m quill.agent data/sales.csv "Which category grew fastest from the first to the last quarter of 2025?"
===== TRAJECTORY (agent.replay) =====
... Step 1 ...  Thought: I'll group by category and compare quarters.
... <code> import pandas as pd; df = pd.read_csv("data/sales.csv"); print(df["Catgory"].unique()) </code>
... Observation: ... KeyError: 'Catgory' ...
... Step 2 ...  Thought: There's no "Catgory" column; it's "category". Let me check columns.
... <code> ... growth = (g[4] - g[1]) / g[1]; final_answer(growth.idxmax()) </code>
... Observation: Final answer: Team

===== RUN RESULT =====
state       : success
steps (dict): 2
token usage : TokenUsage(input_tokens=..., output_tokens=...)
answer      : Team

===== ACTION STEPS =====
  step 1: error='KeyError' | obs='... KeyError: ...'
  step 2: error=None | obs='Final answer: Team'

There it is — step 1 errors, step 2 fixes it, the run still succeeds. That is the heart of Module 2. A step error is an opportunity to self-correct, not a run failure; the only thing that truly ends the run is calling final_answer (or hitting max_steps). If your model doesn’t trip on this dataset, swap in a question with a trap-prone column name to see the correction happen.

6. The smoke tests. The offline tests drive the entire loop with no network or token, using a shared FakeModel that returns scripted code actions — so we can verify the harness (pandas over the CSV, final_answer, the KeyError-then-fix path, RunResult, memory.steps/replay()) deterministically. The one live test (a single real run) is skipped unless you opt in:

uv run pytest module-02/tests/                       # offline (FakeModel) — no token
QUILL_LIVE_TESTS=1 uv run pytest module-02/tests/    # + 1 real LLM run (needs HF_TOKEN)
..........s                                                              [100%]
10 passed, 1 skipped in 0.52s

The offline self-correction test is worth reading — it scripts a bad step then a good one and asserts the run still succeeds:

def test_agent_self_corrects_after_an_error(fake_model):
    bad = _load(CSV) + "\nprint(df['Catgory'].unique())"   # typo -> KeyError
    good = _load(CSV) + "\nprint(df['category'].unique())\nfinal_answer('Team')"
    agent = build_quill(model=fake_model([bad, good]))
    out = agent.run(build_task(CSV, "Which category grew fastest?"))
    assert out == "Team"
    action_steps = [s for s in agent.memory.steps if isinstance(s, ActionStep)]
    assert action_steps[0].error is not None    # step 1 recorded an error...
    assert action_steps[-1].error is None       # ...but the run still succeeded
    assert any(s.is_final_answer for s in action_steps)

Try it yourself

  1. Run the same task with agent.run(task, stream=True) and iterate the generator to watch each ActionStep / FinalAnswerStep arrive live (streaming internals are Module 6).
  2. Drop max_steps to 2 on a question that needs several steps and watch result.state == "max_steps_error" plus the fallback answer.

What Quill v0 deliberately does NOT do. No custom tool or @tool/Tool class — tools=[] (Module 3). No make_model() / LiteLLM swap / token cost via Monitor (Module 4). No Docker/E2B sandbox, no threat model, no "*" wildcard (Module 5). No multi-turn reset=False or step_callbacks (Module 6). No planning (Module 7). No QuillReport / final_answer_checks (Module 8). Quill v0 is deliberately bare — tools=[], local executor, one explicit model. Every module from here adds exactly one capability.

In production

The lab runs on a laptop. Here’s what changes when it’s real.

max_steps is a budget guardrail, not a detail. In production, an agent that loops is a bill that explodes. Cap it low for the job at hand, and alert on state == "max_steps_error" — that state is your signal that a run went sideways and produced a fallback, not a real answer.

The local interpreter is not a security sandbox. Quill v0 runs untrusted, LLM-written Python on your machine. That’s fine for a lab; it is not fine in production. Module 5 moves Quill into a real sandbox (Docker/E2B). One line of warning is all this gets here — the full threat model is Module 5.

Authorized imports are an attack surface. Keep additional_authorized_imports minimal and extend it only by explicit addition — Quill’s frozen list grows by adding entries (pandas, numpy, later matplotlib.*, json, statistics), never by reaching for the "*" wildcard.

Run cost is invisible by default. agent.run(task) hands you the answer and nothing about what it cost. return_full_result=True already gives you result.token_usage; proper accounting (Monitor) arrives in Module 4. My standing rule: I always run with return_full_result=True in any non-toy setting. Flying blind on token usage is how surprise bills happen.

(One more knob you’ll meet later: agent.interrupt() stops an agent cleanly at the end of the current step and then raises — useful for UIs and long runs. It’s a mention here; the UI work is Module 13.)

Concept check

Why this matters. Knowing what a step is, how an agent terminates, and how it self-corrects is the foundation everything else in this course stands on. Memory, sandboxing, planning, structured output, multi-agent teams — they all assume you can already picture the Thought-Code-Observation loop and the ActionStep it records. Get this loop wrong in your head and every later module fights you.

Quiz — five scenarios; pick the best answer. Answers and explanations follow all five.

1. A CodeAgent runs and you watch one trip around its loop: it reasons, writes Python, the interpreter runs it, and an entry is appended to memory. Which is the correct definition of one step?

  • A. One call to a tool.
  • B. One line of executed Python.
  • C. One LLM call → one parsed action → one execution → one ActionStep.
  • D. One LLM message, regardless of whether anything executed.

2. An agent runs for a long time and finishes with state="max_steps_error" and no useful answer. What happened, and what is the real termination mechanism?

  • A. The model decided it was done and set an internal “finished” flag incorrectly.
  • B. The model never called final_answer, so the loop ran to the ceiling; termination happens by calling the final_answer tool, not by a “done” flag.
  • C. A KeyError in one step crashed the run.
  • D. return_full_result=True forced the run to stop early.

3. You inspect agent.memory.steps. The first ActionStep has a non-None error, yet the run’s state is "success". How is that possible?

  • A. The RunResult is reporting a stale state.
  • B. An error in a step is captured in ActionStep.error, fed back to the model, and fixed on a later step — a step error is not a run failure.
  • C. success is the default state and is unrelated to errors.
  • D. The error must have been in a PlanningStep, which doesn’t count.

4. Quill fails with “import of pandas is not allowed.” A teammate’s agent instead loops endlessly and never answers. Which lever fixes each, and what does each protect?

  • A. Both are fixed by max_steps.
  • B. Add "pandas" to additional_authorized_imports for the first; lower max_steps for the second. The import list controls what the sandbox may import; max_steps bounds runaway loops and cost.
  • C. Both are fixed by additional_authorized_imports.
  • D. Raise max_steps for the first; raise it again for the second.

5. A developer wants the number of steps a run took and its token cost. What enables that, versus what plain run() returns?

  • A. run() returns a RunResult by default, so it’s already available.
  • B. Read agent.logs after the run.
  • C. Pass return_full_result=True to get a RunResult with .steps and .token_usage; plain run() returns only the answer payload.
  • D. Token cost is never exposed by smolagents.

Answers.

  1. C. A step is the full Thought-Code-Observation trip recorded as one ActionStep: 1 LLM call → 1 parsed action → 1 execution → 1 ActionStep. “One tool call” (A) and “one line of code” (B) are too granular; “one LLM message regardless of execution” (D) ignores the action and observation that define a step.

  2. B. There is no internal “done” flag (rules out A). An agent stops only by calling the final_answer tool or by hitting max_steps. A step KeyError (C) wouldn’t end the run — it would be captured and fed back. return_full_result=True (D) only changes the return type, not when the run stops.

  3. B. Errors are captured into ActionStep.error, not raised up the stack; the loop feeds the error back and the model self-corrects on a later step. The run state is real (rules out A and C). PlanningStep (D) isn’t where this happens, and it wouldn’t make a captured ActionStep error vanish.

  4. B. Two different problems, two different levers. The import error means a module isn’t on the allow-list — add it to additional_authorized_imports. The endless loop means no convergence — lower max_steps (and investigate why it isn’t calling final_answer). The import list and the step ceiling protect against different failures.

  5. C. Plain run() returns just the answer payload. return_full_result=True returns a RunResult with output, state, steps, token_usage, and timing. agent.logs (B) was removed in 1.21.0 — use agent.memory.steps.

Common pitfalls.

  • Treating print() as debug output. For a CodeAgent, what the code print()s becomes the Observation: of the next step — it’s the agent’s channel to its own future turn. Print the wrong thing (or nothing) and the agent flies blind. (Writing tools so they communicate well is Module 3/7, but the mechanism is right here.)
  • Confusing a step error with a run failure. An error captured in ActionStep.error is an opportunity to self-correct, not the end. Only exhausting max_steps ends a run in state="max_steps_error".
  • Reading the trajectory via agent.logs. Many tutorials still do this; .logs was removed in smolagents 1.21.0. Use agent.memory.steps / agent.replay(). (Bonus stale identifier: don’t reach for HfApiModel either — it’s InferenceClientModel.)

Key takeaways

  • One step = 1 LLM call → 1 parsed action → 1 execution → 1 ActionStep. That’s the unit of an agent run — burn it in.
  • A CodeAgent’s loop is Thought → Code → Observation, and a print() becomes the next step’s Observation: — the agent talks to itself through stdout.
  • An agent stops only by calling final_answer (or by hitting max_steps, which ends in state="max_steps_error"). There is no internal “done” flag.
  • Errors are captured in ActionStep.error, fed back to the model, and fixed on the next step — self-correction is the whole point of code agents.
  • additional_authorized_imports declares what the sandbox may import (pandas/numpy aren’t allowed by default) — and it’s also a security boundary (Module 5).
  • max_steps bounds runaway loops and cost; cap it low.
  • Read a run’s trajectory with agent.replay() (for your eyes) and agent.memory.steps (for your code) — never agent.logs. return_full_result=True gives you a RunResult with state, steps, and token_usage.

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.

Next — Module 3: Tools — Giving Quill New Powers. Right now Quill can only use the tools it’s born with. Module 3 gives it new powers — custom data tools and web search — and shows why a tool looks different to a CodeAgent than to a JSON tool-caller.

Links: Lab code (module-02) · ← Module 1 · Module 3 → · Course index

References

Verified against smolagents 1.26.0 (latest at build time).