Tools: Giving Quill New Powers (smolagents, Module 3)
This is Module 3 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.
In Module 2, Quill could answer a question about a CSV. It worked, but watch what it actually did. Every single run, the model had to re-discover the path to data/sales.csv, re-write the pd.read_csv call, re-guess the column names, re-infer the dtypes, and hand-roll a matplotlib chart. Ask the same question twice and it writes the same boilerplate twice — and it gets it wrong about one time in three. Wrong separator. A column called category typed as Catgory. A plt.show() that opens a window and saves nothing. The code is disposable; it is never reused; the agent is condemned to relearn its own dataset on every loop.
There is a better primitive. Instead of making the model reinvent read_csv and savefig from scratch, you give it tools — reliable, validated, documented Python functions it can simply call. By the end of this module you will know how to give a CodeAgent durable, reusable capabilities two different ways, and you will understand exactly how smolagents turns a plain Python function into something the model knows how to invoke. Quill stops rewriting everything and gets a toolbox.
In this module
You’ll learn:
- Build a tool two ways — the
@tooldecorator (fast) and aToolsubclass (withsetup()for expensive init) — and choose the right one.- Explain how smolagents validates and exposes a tool:
name/description/inputs/output_type,AUTHORIZED_TYPES,validate_arguments, and theget_json_schemabehind@tool.- Distinguish how the same tool is presented to a
CodeAgent(a callable Python function) versus aToolCallingAgent(a native JSON schema).- Use the default tools (
WebSearchTool,VisitWebpageTool,add_base_tools,TOOL_MAPPING) and manage the toolbox at runtime viaagent.tools.- Apply the habits of a good tool — precise docstring, useful
print(), informativeValueError— so the agent corrects itself.You’ll build: Quill’s first reusable toolbox —
load_datasetandprofile_dataframevia@tool, asave_chartToolsubclass that lazily boots matplotlib insetup(), plusWebSearchTool()andVisitWebpageTool()wired intobuild_quill.Theory areas covered: T3 — Tools — 10% of the theory map.
Prerequisites: Module 2 (
CodeAgent, the ReAct loop,run(),print()→Observation,final_answer). You havesmolagents[toolkit]==1.26.0installed,HF_TOKENin.env, pluspandasandmatplotlib. (Reminder: matplotlib is not a smolagents dependency — you add it yourself.)
Where you are in the course
- ✅ Modules 1–2 — the series, Quill, and your first
CodeAgentrunning the ReAct loop. - 👉 Module 3 — Tools (you are here).
- ⬜ Modules 4–15 — models and cost, sandboxing, memory, planning, reliability, MCP and the Hub, multi-agent teams, vision, RAG, deployment, telemetry, and the capstone.
Quill before this module: ad-hoc pandas re-written in every run, guessing the schema each time. Quill after this module: a frozen, reusable data toolbox (load_dataset, profile_dataframe, save_chart) plus web access — capabilities it calls instead of reinventing.
What a tool actually is — and the two ways to build one
A tool is a function you expose to the agent. smolagents describes every tool with four attributes plus its logic:
name— a short, performative identifier (must be a valid Python identifier, not a reserved keyword).description— what the tool does, its inputs, and its output, in plain language.inputs— a dict mapping each argument name to a{"type": ..., "description": ...}sub-dict.output_type— what the tool returns, as one type string.forward— the method that holds the actual logic.
Here is the part beginners skip, and it is the most important sentence in this module: the description and inputs are injected into the agent’s system prompt. They are literally the documentation the model reads to decide whether — and how — to call the tool. A tool with a vague description is a tool the model calls wrong, or never calls at all. The doc is not cosmetic. It is the interface.
flowchart LR
A["Your Python function<br/>or Tool subclass"] -->|"@tool / Tool"| B["name · description<br/>inputs · output_type<br/>forward()"]
B -->|injected at agent init| C["The agent's<br/>system prompt"]
C --> D["The model reads it<br/>and decides to call"]
D -->|"CodeAgent: result = my_tool(...)"| E["forward() runs"]
D -->|"ToolCallingAgent: {name, arguments}"| E
E -->|return value| F["Observation:<br/>next step"]
AUTHORIZED_TYPES and nullable inputs
Both inputs[*]["type"] and output_type must be drawn from a fixed list. As of smolagents 1.26.0, AUTHORIZED_TYPES is exactly:
AUTHORIZED_TYPES = [
"string", "boolean", "integer", "number",
"image", "audio", "array", "object", "any", "null",
]
These are JSON-schema-style names, not Python type names — note "string", not "str", and "number" for floats. An output_type (or input type) outside this list fails at instantiation, not at run time. An input’s type can also be a list of these strings when the argument legitimately accepts more than one type. For an optional input, add "nullable": True to its sub-dict — that is exactly how the built-in GoogleSearchTool declares its filter_year argument, and how Quill’s save_chart declares its optional filename.
Validation happens automatically, at construction
You do not call a validator yourself. When you subclass Tool, smolagents’ Tool.__init_subclass__ wraps your __init__ so that validate_arguments() runs the moment you instantiate the tool. (The base __init__ does almost nothing — it just sets self.is_initialized = False.) Validation checks:
nameis a string, a valid Python identifier, and not a reserved keyword.descriptionandinputsare present and correctly typed; each input carries"type"(drawn fromAUTHORIZED_TYPES) and"description".output_typeis a string inAUTHORIZED_TYPES.- Signature consistency — the parameters of
forwardmust match the keys ofinputsexactly, and the type hints must line up with the declared input types.
This is a safety net with real value: a mismatch between your forward signature and your inputs dict blows up at startup, not fifteen steps into a live run while you are paying for tokens. The lab’s smoke test leans on this directly — it instantiates each tool and calls validate_arguments() to prove the schema is well-formed offline.
Way 1 — the @tool decorator
For a simple, single-purpose tool, the @tool decorator is the recommended path. You write a normal function; smolagents derives the whole schema for you. Here is Quill’s first real tool, load_dataset (the frozen contract is load_dataset(path: str) -> str):
from smolagents import tool
@tool
def load_dataset(path: str) -> str:
"""Load a tabular dataset (CSV or Parquet) and return a short text summary.
Use this first to discover a dataset's shape and column names before writing any
analysis code, so you never have to guess the schema. Prints a one-line summary and
returns the same summary as the observation.
Args:
path: Filesystem path to the dataset (a ``.csv`` or ``.parquet`` file).
"""
import pandas as pd # import INSIDE the function (a "pushable" rule — explained below)
df = pd.read_csv(path)
summary = (
f"Loaded {path}: {df.shape[0]} rows x {df.shape[1]} columns. "
f"Columns: {list(df.columns)}."
)
print(summary) # print() -> the next step's Observation
return summary # the return value also comes back as Observation
Under the hood, @tool calls get_json_schema(tool_function) (a Hugging Face / transformers chat-template utility) to read your function, then builds a dynamically-created Tool subclass — internally called SimpleTool — whose forward is your function. The mapping is exact:
| Source on the function | Becomes |
|---|---|
| The function name | the tool name (load_dataset) |
| The first docstring paragraph | the description |
The Args: section of the docstring | each input’s description |
| The parameter type hints | the input types (path: str → "string") |
| The return type hint | the output_type (-> str → "string") |
Because the schema is derived from the function, the requirements are non-negotiable: a type hint on every parameter and on the return value, plus a docstring with an Args: section. Drop the return hint and get_json_schema cannot derive output_type; drop the Args: section and your inputs have no descriptions for the model to read. This is the single most common beginner mistake — the function runs fine as plain Python but the tool is never properly exposed to the model.
You can verify the derived schema for yourself:
print(load_dataset.name) # "load_dataset"
print(load_dataset.inputs) # {'path': {'type': 'string', 'description': 'Filesystem path ...'}}
print(load_dataset.output_type) # "string"
load_dataset
{'path': {'type': 'string', 'description': 'Filesystem path to the dataset (a ``.csv`` or ``.parquet`` file).'}}
string
Way 2 — subclassing Tool
When you need more than a single function — multiple methods, internal state, or an expensive one-time init — you subclass Tool directly. Now you declare name/description/inputs/output_type as class attributes (not constructor arguments) and write forward yourself. Quill’s save_chart is exactly this case, because it needs to configure matplotlib once before it can ever run. We will look at its full implementation in the next section, but the shape is:
from smolagents import Tool
class save_chart(Tool): # the canonical tool name is exactly "save_chart"
name = "save_chart"
description = "Save the CURRENT matplotlib figure to outputs/ as a PNG and return its path. ..."
inputs = {
"filename": {
"type": "string",
"description": "Optional base filename. '.png' is added if missing.",
"nullable": True, # optional input
}
}
output_type = "string"
def forward(self, filename: str | None = None) -> str:
...
@tool is sugar over exactly this. The decorator builds the same kind of subclass; you would only hand-write the class when you need the extra structure — which brings us to setup().
setup(), lazy init, and the lifecycle of a tool
Some tools are expensive to prepare. Loading a model, opening a database connection, or — in Quill’s case — importing and configuring matplotlib is work you want to do once, and only if the tool is actually used. smolagents gives you exactly one place for that: setup.
Here is the mechanism. A tool’s __call__ is roughly __call__(self, *args, sanitize_inputs_outputs=False, **kwargs). On invocation, if not self.is_initialized it runs self.setup(), then calls self.forward(...). So setup() runs lazily, on the first call only — never at instantiation. Construct ten tools and you load zero models; you pay each tool’s setup cost only when the agent reaches for it.
This is why save_chart is a subclass and not a @tool function. It selects matplotlib’s non-interactive Agg backend — the one that writes PNGs to disk with no display — and that belongs in setup():
class save_chart(Tool):
# ... name / description / inputs / output_type as above ...
def setup(self) -> None:
"""Lazy, one-time init: force the non-interactive 'Agg' backend."""
import matplotlib
matplotlib.use("Agg") # non-interactive: write PNGs, never open a window
super().setup() # flips is_initialized so this never runs twice
def forward(self, filename: str | None = None) -> str:
import datetime, os
import matplotlib.pyplot as plt
os.makedirs("outputs", exist_ok=True)
if not filename:
filename = f"chart-{datetime.datetime.now():%Y%m%d-%H%M%S-%f}"
if not filename.endswith(".png"):
filename = f"{filename}.png"
out_path = os.path.join("outputs", filename)
fig = plt.gcf()
if not fig.get_axes():
raise ValueError(
"No figure to save — draw a chart with matplotlib (e.g. df.plot(...) or "
"plt.plot(...)) BEFORE calling save_chart."
)
fig.savefig(out_path, bbox_inches="tight")
plt.close(fig)
print(f"Saved chart to {out_path}")
return out_path
The key line is super().setup(): the base implementation flips is_initialized to True, so the backend selection runs exactly once across the agent’s whole run. The lab proves the lazy behavior directly — a freshly constructed save_chart() reports is_initialized is False, and only after the first call does it become True.
You will also see sanitize_inputs_outputs=True referenced in the __call__ signature. When set, smolagents coerces a tool’s inputs and outputs through the Agent Types so non-text values (images, audio) round-trip cleanly. We cover Agent Types at the end of this module.
Here is the tool lifecycle at a glance:
| Phase | When it runs | What goes here | Cost |
|---|---|---|---|
__init__ | At construction (save_chart()) | Almost nothing — base sets is_initialized = False; validate_arguments() fires | Cheap; do not load anything heavy |
setup() | Lazily, on the first call only | Expensive one-time init (load a model, pick a backend, open a connection) | Paid once, and only if the tool is used |
__call__ → forward() | Every call | The actual work | Per call |
Managing the toolbox at runtime
An agent’s toolbox is not frozen at construction. agent.tools is a plain Python dict, keyed by each tool’s name. You can add or replace a tool live, without rebuilding the agent:
from smolagents import VisitWebpageTool
agent = build_quill()
print(sorted(agent.tools))
# ['final_answer', 'load_dataset', 'profile_dataframe', 'save_chart', 'visit_webpage', 'web_search']
extra = VisitWebpageTool()
agent.tools[extra.name] = extra # add or replace by name
The dict is keyed by name, which has one consequence worth burning into memory: two tools with the same name overwrite each other. That is harmless when you mean it (swapping one tool for another) and a silent bug when you do not — which is exactly the trap waiting in the next section, where four different built-in tools all answer to name="web_search".
Default tools, add_base_tools, and TOOL_MAPPING
smolagents ships a roster of ready-made tools. As of smolagents 1.26.0 these live in default_tools.py:
| Tool | name | What it does | Needs |
|---|---|---|---|
PythonInterpreterTool | python_interpreter | Runs Python in the restricted local executor | core |
FinalAnswerTool | final_answer | Returns the answer and ends the run | core (always present) |
UserInputTool | user_input | Prompts the user via stdin | core |
WebSearchTool | web_search | Web search with selectable engine (recommended) | ddgs from [toolkit] |
DuckDuckGoSearchTool | web_search | DuckDuckGo search | ddgs from [toolkit] |
GoogleSearchTool | web_search | Google via SerpAPI / Serper | SERPAPI_API_KEY / SERPER_API_KEY |
ApiWebSearchTool | web_search | Generic API search (defaults to Brave) | BRAVE_API_KEY |
VisitWebpageTool | visit_webpage | Fetch a URL, HTML → markdown | markdownify from [toolkit] |
WikipediaSearchTool | wikipedia_search | Wikipedia lookup | wikipedia-api |
SpeechToTextTool | transcriber | Whisper speech-to-text | [transformers] + torch |
Read the name column carefully. Four tools share name="web_search" — WebSearchTool, DuckDuckGoSearchTool, GoogleSearchTool, and ApiWebSearchTool. That is deliberate: they are drop-in interchangeable, so you can swap a search backend without changing anything else. But because agent.tools is a dict keyed by name, you put exactly one of them in an agent at a time. Add two and the second silently clobbers the first. (As of smolagents 1.26.0, the docs recommend WebSearchTool as the default search tool.)
WebSearchTool and VisitWebpageTool
Quill wires in two of these for web context. As of smolagents 1.26.0, the constructor is WebSearchTool(max_results: int = 10, engine: str = "duckduckgo"). Available engines are "duckduckgo" (default), "bing", and "exa" — and Exa was added in 1.26.0 (it requires EXA_API_KEY). The engine list may grow in later versions, so treat it as version-dependent.
One freshness trap matters here. The default DuckDuckGo engine needs the ddgs package (≥9.0.0, shipped by the [toolkit] extra) — not duckduckgo-search, which is the old name; the package was renamed upstream. Any tutorial telling you to pip install duckduckgo-search is stale.
VisitWebpageTool(max_output_length: int = 40000) (name="visit_webpage") fetches a URL, converts the HTML to markdown, and truncates it. It needs markdownify, also from [toolkit].
add_base_tools and TOOL_MAPPING
You can ask an agent to include the base tools by name with add_base_tools=True. Here is what actually happens — and it is not what most tutorials say. As of smolagents 1.26.0, TOOL_MAPPING contains exactly three classes:
TOOL_MAPPING = {
tool_class.name: tool_class
for tool_class in [
PythonInterpreterTool,
DuckDuckGoSearchTool,
VisitWebpageTool,
]
}
When add_base_tools=True, smolagents’ _setup_tools adds those three — with two crucial caveats visible right in the source:
if add_base_tools:
self.tools.update(
{
name: cls()
for name, cls in TOOL_MAPPING.items()
if name != "python_interpreter" or self.__class__.__name__ == "ToolCallingAgent"
}
)
First, python_interpreter is excluded for a CodeAgent (a CodeAgent already executes Python natively — giving it a Python interpreter tool would be redundant) but included for a ToolCallingAgent. Second, FinalAnswerTool is always added to every agent regardless of add_base_tools — it is the termination mechanism you met in Module 2, not an optional extra.
⚠️ Common misconception
“
add_base_tools=Truegives aCodeAgenta Python interpreter tool, and the default toolbox includes a Transcriber.”Both halves are wrong. For a
CodeAgent,python_interpreteris explicitly excluded — the agent is the interpreter; it is only added for aToolCallingAgent. And the default toolbox has no Transcriber: the guided-tour prose still lists “DuckDuckGo, Python interpreter, and Transcriber,” but the actualTOOL_MAPPING(verified against the source as of smolagents 1.26.0) isPythonInterpreterTool+DuckDuckGoSearchTool+VisitWebpageTool. When docs prose and code disagree, the code wins.
This is precisely why Quill does not pass add_base_tools=True. FinalAnswerTool is already there, and for a CodeAgent the python_interpreter tool would be skipped anyway — so it would add nothing. Quill’s toolbox is the three data tools plus exactly one web-search tool and one visit-webpage tool, declared explicitly.
Same tool, two surfaces — and Agent Types
Here is the conceptual core of the module. The same Tool object is presented to the model in two completely different ways depending on the agent class.
To a CodeAgent, a tool is a callable Python function bound into the sandbox. The model writes ordinary Python that calls it directly, and it can compose freely — loop over results, branch on them, chain several tool calls in one step:
summary = profile_dataframe("data/sales.csv")
print(summary)
df = pd.read_csv("data/sales.csv")
totals = df.groupby("category")["net_rev"].sum()
totals.plot(kind="bar")
path = save_chart("category_revenue")
final_answer(f"Team grew fastest. Chart saved at {path}")
To a ToolCallingAgent, the very same tool is presented as a JSON schema (name, description, parameter types) through the model’s native tool-calling API. The agent passes tools_to_call_from=self.tools_and_managed_agents to the model, and the model emits one structured call per turn:
{"name": "profile_dataframe", "arguments": {"path": "data/sales.csv"}}
In both cases, name/description/inputs/output_type are baked into the system prompt at init — the tool’s documentation reaches the model the same way. What differs is the action format, and that difference has a real practical consequence: code-as-action lets a CodeAgent chain and compose many tool calls in a single step (with loops, conditionals, and intermediate variables), where JSON tool-calling typically makes one call per turn. That composability is one of the central advantages reported in the CodeAct paper.
CodeAgent (code-as-action) | ToolCallingAgent (JSON tool calls) | |
|---|---|---|
| Exposed to the model as | Callable Python functions in the sandbox | JSON tool schemas via native tool-calling |
| Action format | A Python code snippet | {"name": ..., "arguments": {...}} |
| Composability (chain / loop / branch) | High — multiple calls in one step | Low — typically one call per turn |
| Parallel calls | Author them in code | Built-in via max_tool_threads (ThreadPoolExecutor) |
| Debuggability | Read the actual Python it ran | Read structured call/argument records |
The ToolCallingAgent class
Now that the JSON surface is clear, here is the other agent class explicitly. As of smolagents 1.26.0:
ToolCallingAgent(
tools,
model,
planning_interval=None,
stream_outputs=False,
max_tool_threads=None,
# ... plus max_steps, verbosity_level, managed_agents, etc. via **kwargs -> MultiStepAgent
)
It emits JSON tool calls validated against each tool’s schema. Internally, the model is given tools_to_call_from, the calls are handled by process_tool_calls, and each one runs through execute_tool_call. The max_tool_threads argument enables parallel tool calls via a ThreadPoolExecutor — handy when a turn issues several independent calls. You will meet ToolCallingAgent again in Module 10, where it becomes Quill’s web_researcher sub-agent.
Choosing between them — the architecture decision of the module
Pick CodeAgent when… | Pick ToolCallingAgent when… |
|---|---|
| The work is reasoning, chaining, and dynamic composition | The work is dispatching atomic, predefined actions |
| You want a “problem solver / programmer” | You want a “dispatcher / controller” |
| You benefit from loops, conditionals, and intermediate state | You need maximum reliability and predictability |
| You can run code in a sandbox (Module 5) | You want clean API interop and parallel tool calls |
The CodeAct paper reports that expressing actions as code can cut the number of steps an agent needs by roughly 30% compared to JSON tool-calling on the tasks it studied (as of the framing smolagents adopts in smolagents 1.26.0) — because one code block can do the work of several JSON round-trips. The cost is that a CodeAgent executes model-generated code, which is a real security concern (the whole of Module 5). A ToolCallingAgent trades expressivity for reliability and easy API interop.
This is the design decision behind Quill. Quill is a CodeAgent because its job is reasoning over data composed in code — group, pivot, compute growth, plot, save — not dispatching a fixed menu of atomic actions. Code-as-action is the natural fit, so we accept the executor and lock it down later.
Agent Types
One more piece closes the loop on “what a tool returns.” When a tool returns something other than text, smolagents wraps it in an Agent Type so it serializes and renders cleanly. As of smolagents 1.26.0:
AgentText— behaves like astr.AgentImage— behaves like aPIL.Image.Image;.to_raw()returns the PIL image,.to_string()returns a path to the serialized image.AgentAudio—.to_raw()returns atorch.Tensor,.to_string()returns a path to the serialized audio.
They share one interface: to_raw() (the underlying object) and to_string() (a string or file path). The coercion happens via handle_agent_input_types / handle_agent_output_types when sanitize_inputs_outputs=True, so a tool that returns an image can round-trip through the agent loop, get rendered in a notebook, and be handed back without losing fidelity. Quill’s tools all return text, so they wrap to AgentText; the real multimodal story — actually passing images to the agent — arrives in Module 11.
Build it: Quill’s first toolbox
Time to assemble everything into Quill. The goal: turn Quill from “re-write pandas every run” into an agent with a real, frozen toolbox — load_dataset and profile_dataframe (via @tool), save_chart (a Tool subclass with a lazy setup()), plus WebSearchTool() and VisitWebpageTool() — all wired into build_quill. At the end, running Quill on the sample CSV profiles the data, writes its own pandas and matplotlib, calls save_chart to write a PNG, and returns a final_answer that includes the saved chart path.
The full, tested code lives at smolagents-course-labs/module-03; the offline smoke tests pass with no network and no token.
Setup. This module’s lab is a cumulative snapshot of the Module 2 code, so first sync the pins. matplotlib is not a smolagents dependency — add it explicitly:
uv pip install "smolagents[toolkit]==1.26.0" "huggingface_hub>=1.0,<2" "pandas>=2.2.3" matplotlib
cp module-03/.env.example module-03/.env # then add your HF token; never commit .env
[toolkit] provides ddgs>=9.0.0 (for WebSearchTool) and markdownify>=0.14.1 (for VisitWebpageTool).
The data tools. load_dataset and profile_dataframe are the two @tool functions you saw above; their frozen signatures are load_dataset(path: str) -> str and profile_dataframe(path: str) -> str. Both share a small _read_table helper that raises an informative ValueError instead of crashing, so the agent can read the message and self-correct:
def _read_table(path: str):
import os
import pandas as pd
if not os.path.exists(path):
raise ValueError(
f"No file at {path!r}. Check the path (it is relative to the working "
"directory) and try again."
)
lower = path.lower()
if lower.endswith(".csv"):
return pd.read_csv(path)
if lower.endswith(".parquet") or lower.endswith(".pq"):
return pd.read_parquet(path)
raise ValueError(
f"Unsupported format for {path!r}. Supported: .csv, .parquet. "
"Convert the file or point me at a supported one."
)
Two engineering rules are baked in on purpose. First, every import lives inside the function or method, and save_chart.__init__ takes no argument other than self — these are the “pushable” rules. We are not pushing to the Hub yet (that is Module 9); we just write the tools so Module 9 can push them with no rewrite. Second, each tool has a precise docstring, print()s a useful one-liner, and raises informative ValueErrors. The full theory of good tools lands in Module 7 — here we just practice the habits.
Wire it into build_quill. The @tool functions are already tool objects; the Tool subclass must be instantiated (save_chart()):
from smolagents import CodeAgent, InferenceClientModel, Model, VisitWebpageTool, WebSearchTool
from .tools import load_dataset, profile_dataframe, save_chart
DEFAULT_MODEL_ID = "Qwen/Qwen2.5-Coder-32B-Instruct"
QUILL_IMPORTS = ["pandas", "numpy", "matplotlib.*"] # superset toward M5; never "*"
def build_quill(model: Model | None = None) -> CodeAgent:
return CodeAgent(
tools=[
load_dataset,
profile_dataframe,
save_chart(), # Tool subclass: instantiate it (setup() runs lazily later)
WebSearchTool(), # name="web_search"; use exactly ONE web-search tool
VisitWebpageTool(), # name="visit_webpage"
],
model=model or InferenceClientModel(
model_id=DEFAULT_MODEL_ID, token=os.environ.get("HF_TOKEN")
),
additional_authorized_imports=QUILL_IMPORTS,
max_steps=8,
)
A few deliberate choices. We add "matplotlib.*" to additional_authorized_imports so the agent can draw a figure before handing it to save_chart — a clean addition toward Module 5’s frozen list (["pandas","numpy","matplotlib.*","json","statistics"]), not a rename, and never the "*" wildcard. We pin an explicit coder model_id rather than relying on InferenceClientModel’s default (the default is documented as subject to change as of smolagents 1.26.0). We cap max_steps=8 — the library default is 20, and a single-CSV job needs far fewer. And we do not pass add_base_tools=True, for the reasons covered above.
Reminder: this is Module 3, so the
model_idis still passed directly. Module 4 turns this into amake_model()helper inquill/config.py— that file does not exist yet.
Runtime toolbox. agent.tools is a dict keyed by name; you can inspect or mutate it live:
agent = build_quill()
print(sorted(agent.tools))
# ['final_answer', 'load_dataset', 'profile_dataframe', 'save_chart', 'visit_webpage', 'web_search']
Run it. Quill reads the trajectory the supported way — agent.replay() and agent.memory.steps (never agent.logs, which was removed in 1.21.0):
uv run python -m quill.agent data/sales.csv "Which product category grew fastest, and back it up with a saved chart?"
─ Step 1 ─
Thought: Profile the dataset first, then compute growth and plot it.
<code>
summary = profile_dataframe("data/sales.csv")
print(summary)
</code>
Observation: Profile of data/sales.csv Shape: 108 rows x 6 columns ...
─ Step 2 ─
<code>
import pandas as pd
df = pd.read_csv("data/sales.csv")
q = df.assign(quarter=pd.to_datetime(df["month"]).dt.quarter)
g = q.groupby(["category", "quarter"])["net_rev"].sum().unstack()
((g[4] - g[1]) / g[1]).plot(kind="bar")
path = save_chart("category_growth")
final_answer(f"Team grew fastest. Chart saved at {path}")
</code>
Observation: Final answer: Team grew fastest. Chart saved at outputs/category_growth.png
A PNG now sits under outputs/. The sample dataset (data/sales.csv) has 108 rows and the columns month, region_code, category, units, net_rev, churn_flag, with categories Free, Pro, and Team.
Tests. Run the offline suite from the repo root:
uv run pytest module-03/tests/ # offline (no network, no token)
QUILL_LIVE_TESTS=1 uv run pytest module-03/tests/ # + 1 real run
....................... s [100%]
23 passed, 1 skipped
The offline tests instantiate all three tools and assert validate_arguments() passes, check the frozen load_dataset/profile_dataframe/save_chart schemas, call profile_dataframe("data/sales.csv") directly for a non-empty profile, drive save_chart on a real figure to confirm a PNG is written, and prove setup() is lazy (is_initialized is False until the first call). The single live test (one real run that produces a PNG) is marked @pytest.mark.live, skipped unless QUILL_LIVE_TESTS=1, and skips cleanly without HF_TOKEN. Live budget: ~1 LLM run.
Try it yourself.
- Add a fourth
@tool,top_n_by(path: str, column: str, n: int) -> str, wire it intobuild_quill, and watch Quill use it. (Remember: a hint on every parameter and the return, plus anArgs:section, or the schema will not derive.) - Swap
WebSearchTool()forWebSearchTool(engine="bing")and note that thenamestaysweb_search— which is exactly why you keep only one web-search tool in the agent at a time.
This lab does not introduce make_model() or a provider swap (Module 4), a Docker/E2B sandbox (Module 5), MCP or push_to_hub/load_tool/ToolCollection (Module 9 — the tools are written in pushable style but not pushed), multi-agent (Module 10), real image/vision input (Module 11), or QuillReport/final_answer_checks (Module 8). Verified against smolagents 1.26.0.
In production
A tool is a dependency, and dependencies fail. A
save_chartthat crashes in production — disk full, missing matplotlib backend, a read-only filesystem — takes the whole run down with it. That is why every Quill tool fails cleanly and informs the agent: aValueErrorwith a readable message (“No file at …”, “draw a chart before calling save_chart”) gives the model something to correct, instead of a stack trace that ends the run. No silenttry/except— the error is either handled explicitly or it propagates.A bad tool is also a cost. A tool whose
descriptionis vague gets called wrong, or not at all — the model burns steps and tokens flailing at an interface it cannot read. Tool documentation is not polish; it is cost engineering, because the description is the only thing the model sees before deciding.Finally, mind the difference between
print()and the return value. As you saw in Module 2, both a tool’sprint()output and its return value come back as the next step’sObservation. A tool that prints a one-line summary — shape, columns, missing-value count — hands the model exactly what it needs to plan its next move. Opinionated take: I make every data toolprint()a one-line summary; it pays for itself in fewer wasted steps.In Module 9 you’ll push
save_chartto the Hub and pull tools from MCP servers, so your toolbox stops being local.
Concept check
Why this matters
An agent is only as good as its tools. A CodeAgent’s reliability is gated almost entirely on this layer: a well-described, well-validated tool is one the model calls correctly the first time; a vague or broken one is wasted steps, burned tokens, and runs that quietly do the wrong thing. Mastering tools — how to build them, how smolagents exposes them, and how the same tool looks different to a CodeAgent versus a ToolCallingAgent — is the single highest-leverage skill in this course. Everything later (sandboxing, multi-agent, MCP) is built on top of it.
1. You are writing a tool that must load a 4 GB embedding model into memory, but you instantiate many tools at agent startup and most runs never touch this one. Which design avoids paying the load cost up front?
- A. A
@toolfunction that loads the model at the top of the function body. - B. A
Toolsubclass that loads the model in__init__. - C. A
Toolsubclass that loads the model insetup(). - D. A
@toolfunction with the model loaded at module import time.
2. A teammate’s @tool function runs perfectly when called directly in Python, but the agent never calls it — it keeps writing its own code instead. The function has type hints on its parameters and the return, but no Args: section in its docstring. What is the most likely cause?
- A. The function needs to be a
Toolsubclass to be callable. - B. Without the
Args:section, the derived schema has no input descriptions, so the model has nothing to read about how to use it. - C.
@toolrequires the function name to match the file name. - D. The tool must be registered with
add_base_tools=True.
3. Your task needs the agent to call three tools in sequence, filter the combined result, and only then answer — ideally in one step. Which agent surface expresses this most naturally?
- A.
ToolCallingAgent, because JSON tool calls are validated against a schema. - B.
CodeAgent, because tools are callable Python functions it can chain and filter in one code block. - C. Either, identically — the surface makes no difference.
- D.
ToolCallingAgentwithmax_tool_threadsset high.
4. You build a CodeAgent with add_base_tools=True. Which statement is correct as of smolagents 1.26.0?
- A. It gets
python_interpreter,web_search,visit_webpage, and a transcriber. - B. It gets
python_interpreterandweb_search, butfinal_answeronly if you also request it. - C. It does not get
python_interpreter(excluded for aCodeAgent); it getsweb_searchandvisit_webpage;final_answeris present regardless. - D.
add_base_toolshas no effect on aCodeAgent.
5. You add both WebSearchTool() and DuckDuckGoSearchTool() to the same agent. What happens, and what is the right practice?
- A. Both coexist; the agent picks whichever is faster per query.
- B. Instantiation fails with a name-collision error.
- C. They share
name="web_search", so in theagent.toolsdict the second overwrites the first — keep exactly one web-search tool per agent. - D. The agent merges their results automatically.
Answers.
1 — C. setup() runs lazily, on the first call only, and only if the tool is actually used — so you never pay the 4 GB load for runs that do not touch it. Loading in __init__ (B) pays at construction; loading at the top of a @tool body (A) or at import (D) pays on every call or at import time. Expensive one-time init belongs in setup(), which is why it exists.
2 — B. @tool derives the schema from the function via get_json_schema, and the Args: section is the source of each input’s description — the documentation the model reads to learn how to call the tool. Hints alone give types but no per-input descriptions. The function being callable in Python is irrelevant; what the model sees is the derived schema. (A, C, D are not how the decorator works.)
3 — B. A CodeAgent exposes tools as callable Python functions, so the model can call all three, filter, and answer inside a single code block — the composability advantage of code-as-action. A ToolCallingAgent (A, D) typically emits one JSON call per turn; max_tool_threads parallelizes independent calls but does not give you in-step filtering and chaining. The surface absolutely makes a difference (C is wrong).
4 — C. For a CodeAgent, python_interpreter is explicitly excluded (the agent already runs Python); TOOL_MAPPING’s other two — web_search (DuckDuckGo) and visit_webpage — are added. FinalAnswerTool is always present regardless of add_base_tools. There is no transcriber in the default toolbox (A) despite what the docs prose says.
5 — C. All the web-search tools share name="web_search", and agent.tools is a dict keyed by name, so the second registration silently overwrites the first — no error (B), no merging (A, D). The fix is to use exactly one web-search tool per agent; that shared name exists precisely so they are drop-in interchangeable.
Common pitfalls
ddgs, notduckduckgo-search. The package was renamed upstream;[toolkit]installsddgs>=9.0.0. Any tutorial that sayspip install duckduckgo-searchis stale and will fail.- The default toolbox has no Transcriber. The guided-tour prose still lists one, but
TOOL_MAPPING(the code) has exactly three classes as of smolagents 1.26.0. When prose and code disagree, the code wins. @toolneeds hints and anArgs:docstring. Forget either andget_json_schemacannot derive a complete schema, so the tool is poorly (or never) exposed to the model. This is the number-one beginner mistake.
Key takeaways
- A tool is a function exposed to the agent via four attributes —
name,description,inputs,output_type— plusforward. Thedescriptionandinputsare injected into the system prompt: the doc is the interface. - Build a tool two ways:
@toolfor simple functions (it derives the schema from hints + anArgs:docstring), and aToolsubclass when you need state or an expensive one-timesetup(). setup()runs lazily on the first call only, never at instantiation — so constructing ten tools loads zero models.- The same tool is a callable Python function for a
CodeAgentand a JSON schema for aToolCallingAgent; code-as-action lets aCodeAgentchain and compose calls in one step. add_base_tools=Truedoes not give aCodeAgentapython_interpretertool (it is excluded forCodeAgent), andFinalAnswerToolis always present regardless.- Four built-in tools share
name="web_search"— use exactly one at a time, becauseagent.toolsis a dict keyed byname. - A good tool
print()s a useful summary and raises informativeValueErrors, so the agent can reason and self-correct instead of crashing.
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 4: Models and Providers. Right now Quill is hardwired to one model. Module 4 introduces make_model() — one place to swap between Hugging Face Inference, LiteLLM, and a local model, and to see what each run costs.
Links: Lab code (module-03) · Module 2 — Your First CodeAgent · Module 4 — Models and Providers · Course index