From a Model Call to an Agent
A classic model call is a function: text goes in, text comes out. The model only knows what is in its request and in its training data. It can neither look up your customer database, nor calculate reliably, nor send an email.
An agent adds a loop around the model. At each turn, the model receives the conversation history and chooses between two actions:
- call a tool (a function of your program) with arguments;
- give the final answer.
When the model requests a tool, your program executes it, adds the result to the history, then calls the model again. The loop stops when the model answers, or when a limit is reached.
An essential point: the model executes nothing itself. It produces a structured request, for example {"tool": "meteo", "args": {"ville": "Lyon"}}. It is your code that decides whether to execute it. This separation is the basis of all the guardrails seen later.
Describing the Tools
Model APIs that support tool calling (tool calling or function calling) expect for each tool a name, a natural-language description, and an argument schema, often in JSON Schema format. The description matters as much as the code: it is what lets the model choose the right tool.
TOOLS_SPEC = [
{"name": "meteo",
"description": "Current temperature in °C for a French city.",
"parameters": {"type": "object",
"properties": {"ville": {"type": "string"}},
"required": ["ville"]}},
]
The model can be wrong: invent a tool that does not exist, forget an argument, pass text instead of a number. Your code must therefore validate each call before executing it.
The Loop, Step by Step
def run_agent(question, model, tools, max_steps=5):
messages = [{"role": "user", "content": question}]
for step in range(max_steps):
decision = model(messages) # the model proposes
if "answer" in decision:
return decision["answer"] # final answer
name, args = decision["tool"], decision.get("args", {})
if name not in tools:
result = f"ERREUR: outil inconnu {name}"
else:
try:
result = tools[name](**args) # your code executes
except Exception as e:
result = f"ERREUR: {e}"
messages.append({"role": "tool", "name": name, "content": str(result)})
return None # limit reached
Three remarks:
- the history (
messages) is the model’s only memory from one turn to the next: everything it needs to know must be in it; - errors are turned into readable messages: the model can then fix its call on the next turn;
- the loop is bounded by
max_steps. Without a limit, a model that keeps calling the same tool over and over would consume time and money indefinitely.
Simulating the Model for Testing
To fine-tune the loop, there is no need to call a real model: a local function that returns predictable decisions is enough. It makes tests reproducible, free, and runnable without a network.
def fake_model(messages):
resultats = [m for m in messages if m["role"] == "tool"]
if not resultats:
return {"tool": "meteo", "args": {"ville": "Lyon"}}
return {"answer": f"It is {resultats[-1]['content']} °C."}
This way you test the nominal path, but also the hard cases: a model that loops, invents a tool, or passes bad arguments. The real model is only plugged in once these behaviors are under control.
When Not to Use an Agent
An agent is useful when the sequence of steps depends on intermediate results. If the steps are always the same (extract, classify, save), a hardcoded workflow is simpler, cheaper, and easier to test. Start with the most deterministic solution that meets the need, and reserve autonomy for the parts that truly need it.
Key Takeaways
- Agent = “the model proposes, the code executes” loop.
- Each tool call is validated before execution; errors go back to the model as text.
- The loop is always bounded.
- A simulated model lets you test the loop deterministically.
Exercises
Solutions and tests stay on the server. In the student space, your code is run and checked, and the tutor gives hints before the solution.
Write the agent loop
Complete
run_agent(question, model, tools, max_steps=5).- The history starts with
{"role": "user", "content": question}. - At each turn, call
model(messages). If it returns ananswerkey, return{"answer": ..., "steps": n}wherenis the number of tools executed. - Otherwise, execute the tool
decision["tool"]withdecision["args"]and add{"role": "tool", "name": ..., "content": str(resultat)}to the history. - Unknown tool: the content added is
"ERREUR: outil inconnu <name>"(this counts as a step). - After
max_stepsmodel calls without an answer, return{"answer": None, "steps": max_steps, "error": "max_steps"}.
def meteo(ville): donnees = {"Lyon": 18, "Lille": 12} return donnees.get(ville, "inconnue") def convertir_f(celsius): return round(celsius * 9 / 5 + 32, 1) TOOLS = {"meteo": meteo, "convertir_f": convertir_f} def fake_model(messages): """Modèle simulé : il décide d'après les résultats d'outils déjà présents.""" resultats = [m for m in messages if m["role"] == "tool"] if not resultats: return {"tool": "meteo", "args": {"ville": "Lyon"}} if len(resultats) == 1: return {"tool": "convertir_f", "args": {"celsius": int(resultats[0]["content"])}} return {"answer": f"Il fait {resultats[-1]['content']} °F à Lyon."} def run_agent(question, model, tools, max_steps=5): messages = [{"role": "user", "content": question}] # à compléter ...- The history starts with
Validate a tool call
Write
validate_call(call, schemas)which returns the list of errors of a call{"tool": ..., "args": {...}}(empty list if the call is valid).schemasmaps each tool to a dictionaryargument -> expected type(a type or a tuple of types).- Tool absent from
schemas: return only["outil inconnu: <name>"]. - For each expected argument that is absent:
"argument manquant: <arg>". - For each unexpected argument:
"argument inattendu: <arg>". - For each argument present with the wrong type:
"type invalide: <arg>".
The order of the errors does not matter.
SCHEMAS = { "meteo": {"ville": str}, "convertir_f": {"celsius": (int, float)}, } def validate_call(call, schemas): errors = [] # à compléter return errors- Tool absent from
Who executes the tool?
In an agent-tool loop, what happens when the model “calls” the
remboursertool?- The model itself performs the refund on the provider's server
- The model returns a structured request; your program decides whether to execute it after verification
- The provider's API executes the Python function remotely
End-of-lesson quiz
Why bound an agent’s loop?
- To speed up the model
- To avoid infinite loops and control cost and duration
- Because APIs refuse more than 5 calls
What should you do when a tool raises an exception?
- Stop the agent with an error trace
- Send the model a readable error message as the result
- Ignore the call and repeat the same request
What is the benefit of a simulated model (local function)?
- It is smarter than the real model
- It makes loop tests deterministic, free, and network-free
- It permanently replaces the model in production
The rest of the course
- Planning and Memory20 min
- Guardrails and Human Approval20 min
- Task Orchestration20 min
- Failures and Recovery20 min