Your First CodeAgent: The ReAct Loop, Step by Step (smolagents, Module 2)
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 withagent.replay()andagent.memory.steps. - Explain how an agent terminates: the
FinalAnswerTool, theis_final_answersignal, and whatRunResultgives you. - Configure the two knobs that bound and empower a
CodeAgentat this stage —max_stepsandadditional_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
CodeAgentis. - Python 3.11 or 3.12.
- An
HF_TOKENin your.env(you set this up in Module 1). pip install 'smolagents[toolkit]==1.26.0' pandas(or useuv, 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
CodeAgentthat 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:
Thought:— the model reasons in plain language about what to do next.- 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```pyfence — the detail of these tags is Module 7). Observation:— what the executed code produced. And here is the piece beginners miss: anything the codeprint()s becomes theObservation:that the model reads at the start of the next step. The agent literally talks to itself through stdout. Aprint()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
ActionStepappended 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 ActionStep | What it holds |
|---|---|
step_number | which step this was (1, 2, 3, …) |
model_output | the raw text the LLM produced (Thought + code block) |
code_action | the Python that was extracted from it |
observations | what the code printed (the next step’s Observation:) |
error | an AgentError if the step failed, else None |
token_usage | tokens spent on this step’s LLM call |
is_final_answer | True 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 argument | Default (as of 1.26.0) | Covered in |
|---|---|---|
tools | (required) | M3 |
model | (required) | M4 |
additional_authorized_imports | [] | M2 (+ security in M5) |
max_steps | 20 | M2 |
add_base_tools | False | M3 |
executor_type | "local" | M5 |
planning_interval | None | M7 |
instructions | None | M7 |
final_answer_checks | None | M8 |
return_full_result | False | M2 |
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() argument | Default | Meaning |
|---|---|---|
task | (required) | the natural-language task string |
stream | False | yield steps live instead of returning at the end (Module 6) |
reset | True | clear memory before this run; reset=False (continue a conversation) is Module 6 |
additional_args | None | extra objects to inject into the run |
max_steps | None | override the constructor’s ceiling for this run |
return_full_result | False | return 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 valuerun()would return by default).state—"success"or"max_steps_error".steps— alist[dict]of serialized steps. Note: these are dicts, not the step objects. For the live objects (to callisinstance(step, ActionStep)), useagent.memory.steps.token_usage— total tokens for the run (Noneoffline, 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_answertool, or it hitmax_steps. If the model never callsfinal_answer, the agent loops all the way to the ceiling and finishes instate="max_steps_error"— burning tokens the whole way. This is the number-one mental trap for beginners, and it’s also why a sanemax_stepsis 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:
- The error is captured into
ActionStep.error(typedAgentError | None), not raised up the stack. - On the next turn,
write_memory_to_messages()feeds that error back to the model as part of the conversation. - 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 class | Parent | Born when… (intro — full detail in M8) |
|---|---|---|
AgentError | Exception | base class for the whole family |
AgentParsingError | AgentError | the model’s output can’t be parsed into an action |
AgentExecutionError | AgentError | the action fails while running |
AgentGenerationError | AgentError | the model call itself fails |
AgentMaxStepsError | AgentError | the step ceiling is reached |
AgentToolCallError | AgentExecutionError | a tool is called with bad arguments |
AgentToolExecutionError | AgentExecutionError | a 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.py — build_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
- Run the same task with
agent.run(task, stream=True)and iterate the generator to watch eachActionStep/FinalAnswerSteparrive live (streaming internals are Module 6). - Drop
max_stepsto2on a question that needs several steps and watchresult.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 thefinal_answertool, not by a “done” flag. - C. A
KeyErrorin one step crashed the run. - D.
return_full_result=Trueforced 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
RunResultis 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.
successis 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"toadditional_authorized_importsfor the first; lowermax_stepsfor the second. The import list controls what the sandbox may import;max_stepsbounds runaway loops and cost. - C. Both are fixed by
additional_authorized_imports. - D. Raise
max_stepsfor 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 aRunResultby default, so it’s already available. - B. Read
agent.logsafter the run. - C. Pass
return_full_result=Trueto get aRunResultwith.stepsand.token_usage; plainrun()returns only the answer payload. - D. Token cost is never exposed by smolagents.
Answers.
-
C. A step is the full Thought-Code-Observation trip recorded as one
ActionStep: 1 LLM call → 1 parsed action → 1 execution → 1ActionStep. “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. -
B. There is no internal “done” flag (rules out A). An agent stops only by calling the
final_answertool or by hittingmax_steps. A stepKeyError(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. -
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 capturedActionSteperror vanish. -
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 — lowermax_steps(and investigate why it isn’t callingfinal_answer). The import list and the step ceiling protect against different failures. -
C. Plain
run()returns just the answer payload.return_full_result=Truereturns aRunResultwithoutput,state,steps,token_usage, andtiming.agent.logs(B) was removed in 1.21.0 — useagent.memory.steps.
Common pitfalls.
- Treating
print()as debug output. For aCodeAgent, what the codeprint()s becomes theObservation: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.erroris an opportunity to self-correct, not the end. Only exhaustingmax_stepsends a run instate="max_steps_error". - Reading the trajectory via
agent.logs. Many tutorials still do this;.logswas removed in smolagents 1.21.0. Useagent.memory.steps/agent.replay(). (Bonus stale identifier: don’t reach forHfApiModeleither — it’sInferenceClientModel.)
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 aprint()becomes the next step’sObservation:— the agent talks to itself through stdout. - An agent stops only by calling
final_answer(or by hittingmax_steps, which ends instate="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_importsdeclares what the sandbox may import (pandas/numpy aren’t allowed by default) — and it’s also a security boundary (Module 5).max_stepsbounds runaway loops and cost; cap it low.- Read a run’s trajectory with
agent.replay()(for your eyes) andagent.memory.steps(for your code) — neveragent.logs.return_full_result=Truegives you aRunResultwithstate,steps, andtoken_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
- How do multi-step agents work? (smolagents conceptual guide)
- Agents — smolagents API reference
- smolagents guided tour
- ReAct: Synergizing Reasoning and Acting in Language Models (Yao et al., 2022) — arXiv 2210.03629
- Executable Code Actions Elicit Better LLM Agents (CodeAct, Wang et al., 2024) — arXiv 2402.01030
Verified against smolagents 1.26.0 (latest at build time).