Search Tools for smolagents Code Agents
smolagents writes Python to call your tools. That changes how you should design a search tool — and makes multi-step research surprisingly compact.


smolagents takes a different bet from most agent frameworks: instead of emitting JSON tool calls, the model writes Python. Your tools are just functions in its namespace, and orchestration is code the model generates on the fly.
For search work this is genuinely nice. “Search five variations of this query, dedupe by domain, read the top three” is one tool call in JSON-land only if you built exactly that tool. In smolagents it’s a loop the model writes itself.
Defining tools
import os
import requests
from urllib.parse import quote_plus
from smolagents import tool
API_KEY = os.environ["SERPLY_API_KEY"]
BASE = "https://api.serply.io/v1"
@tool
def web_search(query: str, num: int = 10) -> list[dict]:
"""Searches the live web and returns ranked results.
Args:
query: The search query, phrased as a Google search.
num: Number of results to return, 1-50.
"""
resp = requests.get(
f"{BASE}/search/q={quote_plus(query)}&num={num}",
headers={"X-Api-Key": API_KEY, "X-Proxy-Location": "US"},
timeout=30,
)
resp.raise_for_status()
return [
{
"title": r.get("title", ""),
"url": r.get("link", ""),
"snippet": r.get("description", ""),
"position": r.get("position"),
}
for r in resp.json().get("results", [])
]
@tool
def read_page(url: str) -> str:
"""Reads a web page and returns its content as markdown.
Args:
url: The full URL of the page to read.
"""
resp = requests.post(
f"{BASE}/request",
headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
json={"url": url, "response_type": "markdown"},
timeout=60,
)
resp.raise_for_status()
return resp.text[:15000]
Notice web_search returns a list[dict], not a formatted string. In a JSON-tool framework you’d format for readability, because the string goes straight into the model’s context. Here the model is writing code against the return value, so structure beats prose — it will filter, sort, and slice this list rather than read it.
The Serply specifics: the search endpoint embeds the query string in the path (/v1/search/q=...), and the scraper’s markdown mode returns text as the body, so .text rather than .json(). Passing "response_type": "full" gives you JSON with raw HTML under data instead.
Running the agent
from smolagents import CodeAgent, InferenceClientModel
agent = CodeAgent(
tools=[web_search, read_page],
model=InferenceClientModel(model_id="Qwen/Qwen2.5-Coder-32B-Instruct"),
additional_authorized_imports=["urllib.parse", "collections", "re"],
max_steps=8,
)
answer = agent.run(
"Compare how three different SERP API providers describe their geo-targeting "
"support. Read their actual docs pages, don't rely on marketing copy."
)
print(answer)
additional_authorized_imports matters. smolagents sandboxes the generated code and blocks imports by default; without collections the model can’t dedupe with a Counter, and it will write something clumsier instead.
What the model actually writes
Given the prompt above, generated code tends to look like this:
providers = ["serply", "serpapi", "brightdata"]
docs = {}
for p in providers:
hits = web_search(f"{p} geo targeting proxy location documentation", num=5)
doc_hits = [h for h in hits if "/docs" in h["url"] or "documentation" in h["title"].lower()]
if doc_hits:
docs[p] = read_page(doc_hits[0]["url"])
That filtering step — preferring URLs with /docs in them — is the kind of thing you’d have to hard-code as a tool parameter in a JSON framework. Here it’s an emergent property of letting the model write the glue.
Guarding the cost
Code agents can write loops, which means they can write expensive loops. Two mitigations.
Cap steps with max_steps, as above. Then wrap the tools with a hard call budget so a single generated for loop can’t fan out to two hundred requests:
class CallBudget:
def __init__(self, limit: int):
self.limit, self.used = limit, 0
def spend(self):
self.used += 1
if self.used > self.limit:
raise RuntimeError(
f"Search budget of {self.limit} calls exhausted. "
"Summarize what you have already gathered."
)
budget = CallBudget(25)
Call budget.spend() at the top of each tool. The error message is written for the model, not for you — smolagents surfaces exceptions back into the loop, and a message that tells it what to do next produces a graceful wrap-up instead of a retry storm.
Rate limits
Serply returns 429 when the window is exhausted, along with x-ratelimit-requests-remaining on normal responses. In a code agent it’s worth converting 429 into a return value rather than an exception, so the model’s own error handling doesn’t spiral:
if resp.status_code == 429:
return [{"title": "RATE LIMITED", "url": "", "snippet":
"Stop searching and answer with what you have.", "position": 0}]
Slightly ugly, but it keeps the return type stable, which matters when the model has already written code that indexes into the list.