---
title: "6.4.1 Tool-calling agents: ReAct and planning"
description: "Tool calling and a ReAct loop with deterministic tools (prices, news, calculator): local LLM, simulated APIs, and failures with their guardrails."
url: "https://pythonparatrading.com/en/python-trading-course/level-6/tool-calling-agents-react-and-planning/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-6/tool-calling-agents-react-and-planning.png"
---

# 6.4.1 · Tool-calling agents: tool calling, ReAct and planning

> An agent is a loop: the model asks for a tool, the program runs it and hands back the result, until the model answers. This lesson builds that loop with three deterministic tools over the level's data, runs it with a local 0.5B LLM and with simulated OpenAI and Anthropic APIs, and catalogs how it fails: the wrong tool, invented arguments, loops and answers that ignore what the tools returned.

- Course: [Level 6: Generative AI, LLMs, agents, big data and alternative data](https://pythonparatrading.com/en/python-trading-course/level-6/)
- Module: 6.4 · AI agents for trading
- Duration: 70 min
- Prerequisites: [6.2.1 · How an LLM works: from the next token to the API](https://pythonparatrading.com/en/python-trading-course/level-6/how-llms-work-apis-and-local-models/), [6.2.2 · Structured prompting: JSON you can validate](https://pythonparatrading.com/en/python-trading-course/level-6/structured-prompting-json-with-pydantic/)

## By the end of this lesson

- Explain what tool calling is: the model runs nothing, it emits a structured request (name and JSON arguments) that the program validates and executes.
- Define deterministic tools with Pydantic schemas and build a ReAct loop with validation, a step limit and repetition detection.
- Run the same loop with a local LLM (Qwen template format) and with the documented shapes of the OpenAI (Responses) and Anthropic (Messages) APIs against local simulators.
- Recognize the typical failure modes, verify the final answer against the tool results, and know when a fixed plan is better than letting the model decide.

## An agent is a loop with tools

An LLM only produces text. To have it look up a price, search for news or do an exact calculation, it is given a
list of **tools** described with a name, an explanation and a JSON schema of their arguments. When the model decides
to use one, it does not run it: it writes a **structured request** (“call `precio` with `ticker=BUSI` and
`fecha=2024-03-15`”). The program around the model validates that request, executes the function, and returns the
result to it as one more message. The model reads the result and decides whether to ask for another tool or to
answer. That is *tool calling* (or *function calling*), and the complete loop is an **agent**.

```mermaid
flowchart TB
  U[Question] --> M[LLM]
  M --> D{Asks for a tool?}
  D -->|yes| V[Validate arguments]
  V --> E[Execute in code]
  E --> O[Observation]
  O --> M
  D -->|no| R[Final answer]
  R --> C[Verify]
```

The **ReAct** pattern (Yao et al., 2022) gave this way of working its name: alternating reasoning (*reason*) and
action (*act*), with the observation from each action as input to the next reasoning step. Compared with asking for
the answer in one go, ReAct lets the model use data that is not in its weights and correct course with what it
observes. The counterpart to ReAct is **explicit planning**: first the complete plan is decided (which tools, in what
order) and then it is executed. When the task is known, the programmer can fix the plan and the model only fills in
the gaps.

Two ideas guide the whole lesson:

1. **The program is in charge.** Every call goes through code that validates the arguments, decides whether it runs
   and records what happened. The model proposes; the code disposes. Lesson 6.4.5 takes this idea to its limit with
   a deterministic risk engine between the agent and the orders.
2. **Tools must be deterministic.** Same argument, same result: that way an agent's trace can be repeated, audited
   and tested, and failures belong to the model or the design, not to the tool.

## Three deterministic tools over the level's data

The tools work on the synthetic level 6 data: the daily closes of the 120 companies in the panel
(`acciones6_precios.parquet`) and the news corpus (`noticias6_corpus.parquet`). The example corpus is in Spanish, and
so are the system prompt, the questions, the tool descriptions and the error messages the model receives: they stay
that way because changing them would change the model's behavior; quoted fragments carry an English gloss in
parentheses. Each tool has a Pydantic model for its arguments (lesson 6.2.2): the JSON schema the model will see comes
from there, and so does the validation.

```python
import ast
import json
import operator
import re

import numpy as np
import pandas as pd
from pydantic import BaseModel, Field, ValidationError

precios = pd.read_parquet("acciones6_precios.parquet")
noticias = pd.read_parquet("noticias6_corpus.parquet")
TICKERS = set(precios.ticker)
FECHA = r"^\d{4}-\d{2}-\d{2}$"


class ArgsPrecio(BaseModel):
    ticker: str = Field(pattern=r"^[A-Z]{4}$")
    fecha: str = Field(pattern=FECHA)


class ArgsNoticias(BaseModel):
    ticker: str = Field(pattern=r"^[A-Z]{4}$")
    desde: str = Field(pattern=FECHA)
    hasta: str = Field(pattern=FECHA)


class ArgsCalculadora(BaseModel):
    expresion: str


def precio(ticker, fecha):
    if ticker not in TICKERS:
        raise ValueError(f"ticker desconocido: {ticker}")
    s = precios[(precios.ticker == ticker) & (precios.fecha <= fecha)]
    if s.empty:
        raise ValueError(f"sin datos hasta {fecha}")
    r = s.iloc[-1]
    return {"ticker": ticker, "fecha": f"{r.fecha:%Y-%m-%d}",
            "cierre": round(float(r.cierre), 2)}


def buscar_noticias(ticker, desde, hasta):
    if ticker not in TICKERS:
        raise ValueError(f"ticker desconocido: {ticker}")
    ini = pd.Timestamp(desde, tz="UTC")
    fin = pd.Timestamp(hasta, tz="UTC") + pd.Timedelta(days=1)
    x = noticias[(noticias.ticker == ticker) & (noticias.ts >= ini)
                 & (noticias.ts < fin)].drop_duplicates("titular")
    return [{"fecha": f"{t:%Y-%m-%d}", "titular": h}
            for t, h in zip(x.ts[:5], x.titular[:5])]


OPS = {ast.Add: operator.add, ast.Sub: operator.sub,
       ast.Mult: operator.mul, ast.Div: operator.truediv,
       ast.USub: operator.neg}


def _evalua(n):
    if isinstance(n, ast.Constant) and type(n.value) in (int, float):
        return n.value
    if isinstance(n, ast.BinOp) and type(n.op) in OPS:
        return OPS[type(n.op)](_evalua(n.left), _evalua(n.right))
    if isinstance(n, ast.UnaryOp) and type(n.op) in OPS:
        return OPS[type(n.op)](_evalua(n.operand))
    raise ValueError("solo números, + - * / y paréntesis")


def calculadora(expresion):
    """Arithmetic without eval(): the expression tree is walked."""
    return round(_evalua(ast.parse(expresion, mode="eval").body), 4)


HERRAMIENTAS = {
    "precio": (precio, ArgsPrecio, "Cierre de una acción en una "
               "sesión (la última sesión en o antes de la fecha)."),
    "buscar_noticias": (buscar_noticias, ArgsNoticias, "Titulares de "
                        "noticias de una acción entre dos fechas "
                        "(AAAA-MM-DD)."),
    "calculadora": (calculadora, ArgsCalculadora, "Evalúa una "
                    "expresión aritmética con + - * / y paréntesis."),
}


def ejecutar(nombre, args):
    """Validates and executes; errors come back as data, not as
    exceptions, so the model can see them."""
    if nombre not in HERRAMIENTAS:
        return {"error": f"herramienta desconocida: {nombre}"}
    f, Args, _ = HERRAMIENTAS[nombre]
    try:
        a = Args.model_validate(args)
    except ValidationError as e:
        return {"error": "argumentos no válidos: " + "; ".join(
            f"{x['loc'][0]}: {x['msg']}" for x in e.errors())}
    try:
        return {"resultado": f(**a.model_dump())}
    except Exception as e:
        return {"error": str(e)}


print(ejecutar("precio", {"ticker": "BUSI", "fecha": "2024-03-16"}))
print(ejecutar("precio", {"ticker": "BUSI", "fecha": "16/03/2024"}))
print(ejecutar("calculadora", {"expresion": "(27.88/26.19 - 1)*100"}))
print(ejecutar("calculadora",
               {"expresion": "__import__('os').getcwd()"}))
print(ejecutar("buscar_noticias", {"ticker": "BUSI",
               "desde": "2024-03-18", "hasta": "2024-03-20"}))
```
```output
{'resultado': {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}}
{'error': "argumentos no válidos: fecha: String should match pattern '^\\d{4}-\\d{2}-\\d{2}$'"}
{'resultado': 6.4528}
{'error': 'solo números, + - * / y paréntesis'}
{'resultado': [{'fecha': '2024-03-19', 'titular': 'Un fondo ofrece comprar Busmar Hogar'}]}
```

Four design decisions that recur in any serious agent:

- **Errors are data.** A date in European format or a nonexistent ticker does not raise an exception that brings the
  program down: they come back as `{"error": …}`, which the model will read as an observation and can correct.
- **No `eval`.** The calculator walks the syntax tree and only accepts numbers and four operations. A model (or text
  injected into a news item the model reads) cannot execute code that way.
- **Explicit dates and sessions.** March 16, 2024 is a Saturday: `precio` returns Friday the 15th's close and says
  which date it used. A tool that silently answered with another session would be a source of hard-to-spot errors.
- **Bounded results.** `buscar_noticias` removes syndicated copies (same headline) and returns at most five
  headlines. Whatever a tool returns enters the model's context at every following step.

## Tool calling under the hood: the local model's template

With a local model you can see that tool calling is not magic: it is text. The chat template of
Qwen2.5-0.5B-Instruct (Apache-2.0, the small model of the `ia` environment; lesson 6.2.1) puts the tool schemas in
the system message between `<tools>` tags and asks the model to reply with
`<tool_call>{"name": …, "arguments": …}</tool_call>`. The model was fine-tuned to follow that format; the program has
to extract the calls from the text and check that they are valid JSON. Qwen's documentation warns about it: the model
is not guaranteed to always follow the protocol, and in production it pays to parse and validate on your own.

```python
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
modelo = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(modelo)
llm = AutoModelForCausalLM.from_pretrained(modelo, dtype=torch.float32)


def esquema(nombres):
    """Tools in the template's function format."""
    return [{"type": "function", "function": {
        "name": n, "description": HERRAMIENTAS[n][2],
        "parameters": HERRAMIENTAS[n][1].model_json_schema()}}
        for n in nombres]


SISTEMA = ("Eres un asistente de research de renta variable. Usa las "
           "herramientas para obtener datos; no inventes cifras. Cuando "
           "tengas la respuesta, contesta en español en una o dos frases.")
mensajes = [{"role": "system", "content": SISTEMA},
            {"role": "user", "content": "¿Cierre de BUSI el 2024-03-15?"}]
texto = tok.apply_chat_template(mensajes, tools=esquema(["precio"]),
                                add_generation_prompt=True,
                                tokenize=False)
print(texto[texto.index("# Tools"):texto.index("<|im_start|>user")])
```
```output
# Tools

You may call one or more functions to assist with the user query.

You are provided with function signatures within <tools></tools> XML tags:
<tools>
{"type": "function", "function": {"name": "precio", "description": "Cierre de una acción en una sesión (la última sesión en o antes de la fecha).", "parameters": {"properties": {"ticker": {"pattern": "^[A-Z]{4}$", "title": "Ticker", "type": "string"}, "fecha": {"pattern": "^\\d{4}-\\d{2}-\\d{2}$", "title": "Fecha", "type": "string"}}, "required": ["ticker", "fecha"], "title": "ArgsPrecio", "type": "object"}}}
</tools>

For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call><|im_end|>
```

## A ReAct loop with the local LLM

The loop is short: generate, look for `<tool_call>` tags, execute each call with `ejecutar` and return the result as
a message with the `tool` role; if there are no calls, that is the final answer. The minimum guardrails live inside
the loop:

- **Step limit** (`max_pasos`): an agent that does not converge is cut off.
- **Invalid JSON** and **unknown tool**: they come back as errors.
- **Repeated call** with the same arguments: it is not executed again; the model is reminded that it already has the
  result.
- **Trace**: every call, with its arguments and its result, is recorded for auditing and verification.

Two tasks, each with its trap: a percentage change (two prices and a calculation) and the close of a ticker that does
not exist. Each step of the small model costs several seconds of CPU, so the lesson sticks to two; the news tool is
used in exercise 1.

```python
LLAMADA = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.S)


def agente_local(pregunta, nombres, max_pasos=5):
    msgs = [{"role": "system", "content": SISTEMA},
            {"role": "user", "content": pregunta}]
    traza, vistos = [], set()
    for paso in range(1, max_pasos + 1):
        e = tok.apply_chat_template(msgs, tools=esquema(nombres),
                                    add_generation_prompt=True,
                                    return_tensors="pt",
                                    return_dict=True)
        with torch.inference_mode():
            out = llm.generate(**e, max_new_tokens=80, do_sample=False,
                               pad_token_id=tok.eos_token_id)
        n = e["input_ids"].shape[1]
        txt = tok.decode(out[0, n:], skip_special_tokens=True).strip()
        llamadas = LLAMADA.findall(txt)
        print(f"  step {paso} ({n} input tokens)")
        if not llamadas:
            return txt, traza
        msgs.append({"role": "assistant", "content": txt})
        for crudo in llamadas:
            try:
                c = json.loads(crudo)
                nombre, args = c["name"], c.get("arguments", {})
            except (json.JSONDecodeError, KeyError, TypeError):
                nombre, args = None, None
            clave = json.dumps([nombre, args], sort_keys=True)
            if nombre is None:
                r = {"error": "llamada con JSON no válido"}
            elif clave in vistos:
                r = {"error": "llamada repetida: ya tienes el resultado"}
            else:
                r = ejecutar(nombre, args)
            vistos.add(clave)
            traza.append((nombre, args, r))
            print("    ", nombre, args, "=>",
                  json.dumps(r, ensure_ascii=False)[:70])
            msgs.append({"role": "tool",
                         "content": json.dumps(r, ensure_ascii=False)})
    return None, traza


BASICAS = ["precio", "buscar_noticias", "calculadora"]
TAREAS = [
    "¿Cuánto cambió en porcentaje el cierre de BUSI entre el "
    "2024-03-15 y el 2024-03-22?",
    "¿Cuál fue el cierre de ACME el 2024-03-15?"]
resultados = []
for pregunta in TAREAS:
    print(pregunta)
    final, traza = agente_local(pregunta, BASICAS)
    resultados.append((pregunta, final, traza))
    print("  ANSWER:", final)
```
```output
¿Cuánto cambió en porcentaje el cierre de BUSI entre el 2024-03-15 y el 2024-03-22?
  step 1 (567 input tokens)
     precio {'ticker': 'BUSI', 'fecha': '2024-03-15'} => {"resultado": {"ticker": "BUSI", "fecha": "2024-03-15", "cierre": 26.1
     precio {'ticker': 'BUSI', 'fecha': '2024-03-22'} => {"resultado": {"ticker": "BUSI", "fecha": "2024-03-22", "cierre": 27.8
  step 2 (734 input tokens)
  ANSWER: El cambio en el cierre del producto BUSI entre el 2024-03-15 y el 2024-03-22 fue de 27.88%.
¿Cuál fue el cierre de ACME el 2024-03-15?
  step 1 (548 input tokens)
     precio {'ticker': 'ACME', 'fecha': '2024-03-15'} => {"error": "ticker desconocido: ACME"}
  step 2 (612 input tokens)
  ANSWER: El cierre del ticker ACME el 2024-03-15 fue de $10.
```

In the first task (by what percentage BUSI's close changed between 2024-03-15 and 2024-03-22) the model does well
what seemed hard: it asks for both closes in the same turn (two `<tool_call>` in a row), with the correct ticker and
dates, and receives 26.19 and 27.88. And it fails at what seemed easy: it does not call the calculator and answers
that the change “fue de 27.88%” (was 27.88%), the second close turned into a percentage. The real change is 6.45%. In
the second task (ACME's close on 2024-03-15), the ticker does not exist and the tool says so (“ticker desconocido”,
unknown ticker); the model receives the error and answers that the close “fue de \$10” (was \$10). No guardrail in
the loop fires: the calls were valid, there were no repetitions and there were steps to spare. Input tokens grow at
every step (from 567 to 734 in the first task) because the model rereads the whole conversation, results included.

## Verifying the final answer against the trace

The loop's guardrails do not see the most dangerous error, because it is in the final answer and not in a call. The
answer can be verified against the trace with two simple, deterministic and cheap rules:

1. Every figure in the answer must appear in some successful tool result (or in the question).
2. Every percentage must come from a tool that computes (here, the calculator), not from the model's head.

And a third, about process: if a tool returned an error and nothing resolved it afterwards, the answer cannot give
the data point that tool was supposed to bring.

```python
NUM = re.compile(r"\d[\d.,]*\d|\d")


def cifras(s):
    out = []
    for t in NUM.findall(s or ""):
        if "," in t:
            t = t.replace(".", "").replace(",", ".")
        elif re.fullmatch(r"\d{1,3}(\.\d{3})+", t):
            t = t.replace(".", "")
        out.append(float(t.rstrip(".")))
    return out


def cerca(x, ys):
    return any(abs(x - y) <= 0.006 * max(1.0, abs(y)) for y in ys)


def verificar(pregunta, final, traza):
    if final is None:
        return ["no answer: step limit"]
    vistas, calculadas, errores = [], [], []
    for nombre, args, r in traza:
        if "resultado" in r:
            vistas += cifras(json.dumps(r["resultado"]))
            if nombre in ("calculadora", "variacion"):
                calculadas += cifras(json.dumps(r["resultado"]))
        else:
            errores.append(nombre)
    fallos = []
    for x in cifras(final):
        if not cerca(x, vistas + cifras(pregunta)):
            fallos.append(f"figure without a source: {x:g}")
    for p in re.findall(r"(\d[\d.,]*)\s*%", final):
        if not cerca(cifras(p)[0], calculadas):
            fallos.append(f"percentage without a calculation: {p}%")
    if errores and not vistas:
        fallos.append("unresolved tool error")
    return fallos


for pregunta, final, traza in resultados:
    print(pregunta[:45], "|", verificar(pregunta, final, traza) or "OK")
```
```output
¿Cuánto cambió en porcentaje el cierre de BUS | ['percentage without a calculation: 27.88%']
¿Cuál fue el cierre de ACME el 2024-03-15? | ['figure without a source: 10', 'unresolved tool error']
```

The verifier rejects both answers: the 27.88% does not come from any calculation, and the 10 does not come from any
result and, on top of that, an error was left unresolved. What to do with a rejected answer depends on the use case:
return the reason to the model as one more observation and give it another attempt, reply “could not be verified” or
hand the case to a person. What is not done is presenting it as good. The rules are few and crude (a figure can be in
the trace and still be misused), but they cost microseconds and catch the two most expensive failures: invented
figures and calculations not done.

## Planning instead of improvising

When the task is known (“change in a price between two dates”), the model does not need to discover the plan on
every run. There are two ways to fix it:

- **A higher-level tool.** `variacion(ticker, desde, hasta)` does both lookups and the calculation in code. The model
  only has to extract three parameters from the question and transcribe the result.
- **Plan and execute.** The program holds the plan (start price, end price, calculation, report) and the model only
  fills in the parameters, or only writes at the end. It is the *plan-and-execute* pattern: less freedom, fewer
  failures, more predictable traces.

```mermaid
flowchart TB
  P[Question] --> E[LLM extracts data]
  E --> V[Validate schema]
  V --> H[Calculation in code]
  H --> R[LLM writes]
  R --> C[Verify]
```

```python
class ArgsVariacion(BaseModel):
    ticker: str = Field(pattern=r"^[A-Z]{4}$")
    desde: str = Field(pattern=FECHA)
    hasta: str = Field(pattern=FECHA)


def variacion(ticker, desde, hasta):
    a, b = precio(ticker, desde), precio(ticker, hasta)
    return {"desde": a, "hasta": b, "variacion_pct":
            round(100 * (b["cierre"] / a["cierre"] - 1), 2)}


HERRAMIENTAS["variacion"] = (
    variacion, ArgsVariacion, "Variación porcentual del cierre de una "
    "acción entre dos fechas (AAAA-MM-DD), calculada con los dos "
    "cierres.")
final, traza = agente_local(TAREAS[0], ["variacion", "buscar_noticias"])
print("  ANSWER:", final)
print("  verification:", verificar(TAREAS[0], final, traza) or "OK")
```
```output
  step 1 (533 input tokens)
     variacion {'ticker': 'BUSI', 'desde': '2024-03-15', 'hasta': '2024-03-22'} => {"resultado": {"desde": {"ticker": "BUSI", "fecha": "2024-03-15", "cie
  step 2 (691 input tokens)
  ANSWER: El cambio en el cierre de la acción BUSI entre el 2024-03-15 y el 2024-03-22 fue de 6.45%.
  verification: OK
```

With `variacion` the model makes a single call with the three correct parameters, receives the change computed in
code and transcribes it: 6.45%, and the verifier accepts it. It is the same model with the same system prompt; the
only thing that has changed is that it no longer has to chain three calls or do a calculation. In an agent, tool
design weighs as much as the model: the higher the level of the tool, the fewer decisions are left to the model and
the fewer ways there are to fail. The price is flexibility: `variacion` is no use for questions nobody anticipated,
and for those a ReAct loop (with its verifier) is still needed.

## Commercial APIs: same ideas, different shape

The OpenAI and Anthropic APIs do the same thing with structured messages instead of tags in the text:

| | OpenAI (Responses API) | Anthropic (Messages API) |
|---|---|---|
| Define a tool | `{"type": "function", "name", "description", "parameters"}` | `{"name", "description", "input_schema"}` |
| The model requests a call | item `{"type": "function_call", "call_id", "name", "arguments"}` (arguments as JSON text) | block `{"type": "tool_use", "id", "name", "input"}` (arguments as an object) and `stop_reason: "tool_use"` |
| Return the result | `{"type": "function_call_output", "call_id", "output"}` in `input` | block `{"type": "tool_result", "tool_use_id", "content"}` in a user message (`is_error` for errors) |
| Several calls at once | possible (`parallel_tool_calls`) | possible; all the results in a single message |

Shapes according to each provider's documentation, accessed on October 23, 2026[^1]. The real code, which is not run
here because it needs keys and network access:

```python skip
from openai import OpenAI

cliente = OpenAI()                      # OPENAI_API_KEY from the environment
herramientas = [{"type": "function", "name": n,
                 "description": HERRAMIENTAS[n][2],
                 "parameters": HERRAMIENTAS[n][1].model_json_schema()}
                for n in BASICAS]
entrada = [{"role": "user", "content": TAREAS[0]}]
r = cliente.responses.create(model="gpt-5", input=entrada,
                             tools=herramientas)
for item in r.output:
    if item.type == "function_call":
        res = ejecutar(item.name, json.loads(item.arguments))
        entrada += [item, {"type": "function_call_output",
                           "call_id": item.call_id,
                           "output": json.dumps(res)}]
```

```python skip
import anthropic

cliente = anthropic.Anthropic()         # ANTHROPIC_API_KEY from the environment
herramientas = [{"name": n, "description": HERRAMIENTAS[n][2],
                 "input_schema": HERRAMIENTAS[n][1].model_json_schema()}
                for n in BASICAS]
mensajes = [{"role": "user", "content": TAREAS[0]}]
r = cliente.messages.create(model="claude-opus-5-5", max_tokens=1024,
                            tools=herramientas, messages=mensajes)
if r.stop_reason == "tool_use":
    resultados = [{"type": "tool_result", "tool_use_id": b.id,
                   "content": json.dumps(ejecutar(b.name, b.input))}
                  for b in r.content if b.type == "tool_use"]
    mensajes += [{"role": "assistant", "content": r.content},
                 {"role": "user", "content": resultados}]
```

To run that same code without network access, the official clients are pointed at **local simulators**: a
`MockTransport` (from `httpx` for the OpenAI SDK and from `httpx2` for the Anthropic 2.x SDK) that answers with the
documented shape. Behind the simulator there is no model but a **script**: a fixed policy that decides the next
action by looking at how many tool results are already in the conversation. That way the typical failures can be
triggered at will to check that the loop contains them:

| Scenario | What the simulated “model” does |
|---|---|
| `correcto` (correct) | Asks for both prices at once, then the calculator, and answers with the computed percentage |
| `herramienta_equivocada` (wrong tool) | Searches for news instead of prices and answers without a figure |
| `argumentos_inventados` (invented arguments) | Asks for the price of “BUSMAR” with date “15/03/2024”; after the validation error, it corrects itself and continues like the correct one |
| `bucle` (loop) | Asks for the same price over and over, even though the loop warns it that it already has it |

[^1]: OpenAI, “Function calling” (Responses API), and Anthropic, “Tool use with Claude”, accessed on October 23, 2026.
Both documentations warn, each in its own way, that the model can request several calls in the same turn; OpenAI
lets you disable this with `parallel_tool_calls` and Anthropic with `disable_parallel_tool_use`.

```python
import anthropic
import httpx
import httpx2
import openai
import tiktoken

PREGUNTA = TAREAS[0]
DESDE, HASTA = "2024-03-15", "2024-03-22"


def guion(escenario, resultados):
    """Next action of the simulated “model”: a list of calls
    (name, args) or the final text. `resultados` = tool outputs
    received so far (dictionaries)."""
    n = len(resultados)
    if escenario == "bucle":
        return [("precio", {"ticker": "BUSI", "fecha": DESDE})]
    if escenario == "herramienta_equivocada":
        if n == 0:
            return [("buscar_noticias", {"ticker": "BUSI",
                     "desde": DESDE, "hasta": HASTA})]
        return "BUSI subió por la oferta de compra de un fondo."
    if escenario == "argumentos_inventados":
        if n == 0:
            return [("precio", {"ticker": "BUSMAR",
                                "fecha": "15/03/2024"})]
        resultados = resultados[1:]
        n -= 1
    if n == 0:
        return [("precio", {"ticker": "BUSI", "fecha": DESDE}),
                ("precio", {"ticker": "BUSI", "fecha": HASTA})]
    if n == 2:
        a, b = (r["resultado"]["cierre"] for r in resultados[:2])
        return [("calculadora", {"expresion": f"({b}/{a} - 1)*100"})]
    v = resultados[2]["resultado"]
    return f"El cierre de BUSI varió un {v:.2f} % entre {DESDE} y {HASTA}."


ENC = tiktoken.get_encoding("o200k_base")
REGISTRO = []                      # tokens of each request received


def sim_openai(request):
    cuerpo = json.loads(request.content)
    REGISTRO.append(len(ENC.encode(request.content.decode())))
    res = [json.loads(x["output"]) for x in cuerpo["input"]
           if isinstance(x, dict) and x.get("type") == "function_call_output"]
    accion = guion(cuerpo["model"], res)
    if isinstance(accion, str):
        salida = [{"type": "message", "id": "msg_1", "role": "assistant",
                   "status": "completed", "content": [
                       {"type": "output_text", "text": accion,
                        "annotations": []}]}]
    else:
        salida = [{"type": "function_call", "id": f"fc_{len(res)}_{i}",
                   "call_id": f"call_{len(res)}_{i}", "name": nom,
                   "arguments": json.dumps(args), "status": "completed"}
                  for i, (nom, args) in enumerate(accion)]
    return httpx.Response(200, json={
        "id": "resp_sim", "object": "response", "created_at": 0,
        "status": "completed", "model": cuerpo["model"],
        "output": salida, "parallel_tool_calls": True,
        "tool_choice": "auto", "tools": []})


def sim_anthropic(request):
    cuerpo = json.loads(request.content)
    REGISTRO.append(len(ENC.encode(request.content.decode())))
    res = [json.loads(b["content"]) for m in cuerpo["messages"]
           if m["role"] == "user" and isinstance(m["content"], list)
           for b in m["content"] if b.get("type") == "tool_result"]
    accion = guion(cuerpo["model"], res)
    if isinstance(accion, str):
        contenido, parada = [{"type": "text", "text": accion}], "end_turn"
    else:
        contenido = [{"type": "tool_use", "id": f"toolu_{len(res)}_{i}",
                      "name": nom, "input": args}
                     for i, (nom, args) in enumerate(accion)]
        parada = "tool_use"
    return httpx2.Response(200, json={
        "id": "msg_sim", "type": "message", "role": "assistant",
        "model": cuerpo["model"], "content": contenido,
        "stop_reason": parada, "stop_sequence": None,
        "usage": {"input_tokens": 0, "output_tokens": 0}})


oai = openai.OpenAI(
    api_key="sk-simulado", base_url="http://simulado/v1",
    http_client=httpx.Client(transport=httpx.MockTransport(sim_openai)))
ant = anthropic.Anthropic(
    api_key="sk-simulado", base_url="http://simulado",
    http_client=httpx2.Client(
        transport=httpx2.MockTransport(sim_anthropic)))
r = oai.responses.create(model="correcto", input=PREGUNTA)
print([(o.type, o.name, o.arguments) for o in r.output])
m = ant.messages.create(model="correcto", max_tokens=200,
                        messages=[{"role": "user", "content": PREGUNTA}])
print(m.stop_reason, [(b.type, b.name, b.input) for b in m.content])
```
```output
[('function_call', 'precio', '{"ticker": "BUSI", "fecha": "2024-03-15"}'), ('function_call', 'precio', '{"ticker": "BUSI", "fecha": "2024-03-22"}')]
tool_use [('tool_use', 'precio', {'ticker': 'BUSI', 'fecha': '2024-03-15'}), ('tool_use', 'precio', {'ticker': 'BUSI', 'fecha': '2024-03-22'})]
```

There is a single loop, with a small adapter per provider: how calls are requested, how they are read and how the
results are returned. The guardrails are the same as in the local agent, plus a limit on the tokens sent.

```python
def ejecutar_seguro(nombre, args, vistos):
    clave = json.dumps([nombre, args], sort_keys=True)
    if clave in vistos:
        return {"error": "llamada repetida: ya tienes el resultado"}
    vistos.add(clave)
    return ejecutar(nombre, args)


def agente_api(proveedor, escenario, max_pasos=6):
    REGISTRO.clear()
    historial = [{"role": "user", "content": PREGUNTA}]
    vistos, eventos = set(), []
    for paso in range(1, max_pasos + 1):
        if proveedor == "openai":
            r = oai.responses.create(model=escenario, input=historial)
            calls = [(o.call_id, o.name, json.loads(o.arguments))
                     for o in r.output if o.type == "function_call"]
            final = r.output_text
            historial += [o.model_dump(exclude_none=True)
                          for o in r.output]
        else:
            r = ant.messages.create(model=escenario, max_tokens=500,
                                    messages=historial)
            calls = [(b.id, b.name, b.input) for b in r.content
                     if b.type == "tool_use"]
            final = "".join(b.text for b in r.content
                            if b.type == "text")
            historial.append({"role": "assistant", "content": [
                b.model_dump(exclude_none=True) for b in r.content]})
        if not calls:
            eventos.append((paso, "answer"))
            return final, eventos, list(REGISTRO)
        bloques = []
        for cid, nombre, args in calls:
            res = ejecutar_seguro(nombre, args, vistos)
            tipo = "error" if "error" in res else "ok"
            eventos.append((paso, f"{nombre}:{tipo}"))
            if proveedor == "openai":
                historial.append({"type": "function_call_output",
                                  "call_id": cid,
                                  "output": json.dumps(res)})
            else:
                bloques.append({"type": "tool_result",
                                "tool_use_id": cid,
                                "content": json.dumps(res),
                                "is_error": "error" in res})
        if bloques:
            historial.append({"role": "user", "content": bloques})
    eventos.append((max_pasos, "cutoff"))
    return None, eventos, list(REGISTRO)


ESCENARIOS = ["correcto", "herramienta_equivocada",
              "argumentos_inventados", "bucle"]
for esc in ESCENARIOS:
    for prov in ("openai", "anthropic"):
        final, ev, tokens = agente_api(prov, esc)
        print(f"{esc:22s} {prov:9s} steps={ev[-1][0]}"
              f" calls={sum(':' in e for _, e in ev)}"
              f" errors={sum(e.endswith('error') for _, e in ev)}"
              f" tokens={sum(tokens):5d} | {final}")
```
```output
correcto               openai    steps=3 calls=3 errors=0 tokens=  630 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
correcto               anthropic steps=3 calls=3 errors=0 tokens=  643 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
herramienta_equivocada openai    steps=2 calls=1 errors=0 tokens=  221 | BUSI subió por la oferta de compra de un fondo.
herramienta_equivocada anthropic steps=2 calls=1 errors=0 tokens=  235 | BUSI subió por la oferta de compra de un fondo.
argumentos_inventados  openai    steps=4 calls=4 errors=1 tokens= 1065 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
argumentos_inventados  anthropic steps=4 calls=4 errors=1 tokens= 1101 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
bucle                  openai    steps=6 calls=6 errors=5 tokens= 1643 | None
bucle                  anthropic steps=6 calls=6 errors=5 tokens= 1763 | None
```

Both providers give exactly the same trace, because the script is the same and the loop only changes in the shape of
the messages. By scenario:

- **Correct** (`correcto`): three requests (two prices in parallel, the calculator, the answer) and a computed 6.45%.
- **Wrong tool** (`herramienta_equivocada`): it searches for news, receives the headline about the offer and answers
  something plausible (“subió por la oferta de compra de un fondo”, rose on a fund's takeover offer) that does not
  answer the question: there is no percentage. The loop has no way of knowing; one more verifier rule (if the
  question asks for a percentage, the answer has to give it, computed) would flag it.
- **Invented arguments** (`argumentos_inventados`): the ticker “BUSMAR” and the date “15/03/2024” fail validation; the
  error comes back as an observation, the “model” corrects itself and the agent finishes correctly in four steps. A
  capable model usually does this; a small one, not always.
- **Loop** (`bucle`): the first call works and the next five are repetitions that the loop does not execute. The
  six-step limit cuts it off without an answer, which is the correct outcome: better nothing than something
  unverified.

```python
final, ev, tokens_bucle = agente_api("anthropic", "bucle", max_pasos=30)
_, _, tokens_ok = agente_api("anthropic", "correcto")
acum = np.cumsum(tokens_bucle)
print("tokens per request, steps 1-6:", tokens_bucle[:6])
print("steps 25-30:", tokens_bucle[24:30])
print("cumulative after 6, 12 and 30 steps:", acum[5], acum[11], acum[29])
print("correct case, total:", sum(tokens_ok), "in", len(tokens_ok),
      "requests")
```
```output
tokens per request, steps 1-6: [53, 160, 251, 342, 433, 524]
steps 25-30: [2253, 2344, 2435, 2526, 2617, 2708]
cumulative after 6, 12 and 30 steps: 1763 6818 41639
correct case, total: 643 in 3 requests
```

*Fig. — Tokens sent to the simulated Anthropic API, cumulative by step, in the loop scenario without a step limit: each request resends the whole conversation.* Convex curve from 0 to 30 steps. At the 6-step limit (highlighted line) 1,763 tokens have been sent; at 12, 6,818; at 30, 41,639. The correct case ends in 3 steps with 643 tokens.

Each request resends the whole conversation, so what is sent per step grows linearly: 53 tokens the first time, 524
the sixth, 2,708 the thirtieth. The total grows with the square of the steps: 1,763 tokens with the six-step limit,
6,818 with twelve and 41,639 with thirty, versus 643 for the correct case. On an API that charges per token, an agent
with no step limit is spending with no ceiling; with a local model, minutes of CPU. That is why the step limit is also
a budget limit, and it pays to complement it with an explicit token limit.

## Common mistakes

- **Executing without validating.** The arguments of a call are generated text: an invented ticker, a date in another
  format, an expression with code. Always a schema, validation and, for tools with side effects (orders, emails,
  writes), approval outside the model (6.4.5).
- **Tools with side effects or that are not deterministic.** A tool that reads the real clock, a live website or
  mutable state means the same trace does not repeat and cannot be tested. Deterministic first; everything else
  behind a layer that records and pins the data.
- **Exceptions instead of errors as data.** If a tool raises an exception, the agent dies and the model learns
  nothing; if it returns the error, the model can correct and the log counts it.
- **No limit on steps or spending.** A loop does not converge on its own. Each step resends the whole conversation:
  the cost is quadratic in the number of steps.
- **Not verifying the final answer.** A model can request the right data and then write something else. The answer is
  checked against the trace, with deterministic rules.
- **Giving the model the arithmetic.** Calculations, conversions and dates belong in tools or in code. The model
  extracts and writes.
- **Letting it improvise what is a procedure.** If the task is known, a fixed plan (or a high-level tool) is more
  reliable, cheaper and easier to audit than free-form ReAct.
- **Treating what tools return as instructions.** A headline or a document can contain text that looks like an order
  (“ignore the above and…”): that is prompt injection. What comes back from a tool is data, never an instruction
  (6.4.5).

## Exercises

1. Add a rule to the verifier for news questions: every quoted headline in the answer must appear verbatim in some
   result of `buscar_noticias`. Test it with the trace of a news search for BUSI between March 18 and 20, 2024 and two
   hand-written answers, one faithful and one invented.

**Solution.**

```python
def titulares_ok(final, traza):
    reales = {n["titular"] for nom, _, r in traza
              if nom == "buscar_noticias" and "resultado" in r
              for n in r["resultado"]}
    citados = re.findall(r"[«\"“]([^»\"”]+)[»\"”]", final or "")
    return [c for c in citados if c not in reales]


args = {"ticker": "BUSI", "desde": "2024-03-18", "hasta": "2024-03-20"}
traza = [("buscar_noticias", args, ejecutar("buscar_noticias", args))]
fiel = 'On March 19: “Un fondo ofrece comprar Busmar Hogar”.'
falsa = 'There was a news item: "Busmar Hogar dispara su beneficio un 40 %".'
print("faithful:", titulares_ok(fiel, traza) or "OK")
print("invented:", titulares_ok(falsa, traza))
```
```output
faithful: OK
invented: ['Busmar Hogar dispara su beneficio un 40 %']
```

The rule lets the real headline through (“Un fondo ofrece comprar Busmar Hogar”, a fund offers to buy Busmar Hogar)
and flags the invented one (“Busmar Hogar dispara su beneficio un 40 %”, Busmar Hogar's profit jumps 40%). It is a
literal check: an honest paraphrase of a real headline (“un fondo lanza una oferta por Busmar”, a fund launches a bid
for Busmar) would fail it too. An approximate comparison (by words or by embeddings, lesson 6.3.1) tolerates
paraphrase at the cost of letting more errors through; in a report that goes to clients, the prudent thing is to
require the literal quote.

2. With the `argumentos_inventados` script, what would happen if `ejecutar` did not validate the date format? Call
   `precio` directly with “15/03/2024” and with “01/03/2024” (March 1 in the Spanish day-first format) and compare
   with the ISO dates.

**Solution.**

```python
for f in ["15/03/2024", "2024-03-15", "01/03/2024", "2024-03-01"]:
    print(f, precio("BUSI", f))
```
```output
15/03/2024 {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}
2024-03-15 {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}
01/03/2024 {'ticker': 'BUSI', 'fecha': '2024-01-03', 'cierre': 27.75}
2024-03-01 {'ticker': 'BUSI', 'fecha': '2024-03-01', 'cierre': 25.2}
```

Without validation, “15/03/2024” works by accident: pandas recognizes the format and interprets it correctly. But
“01/03/2024”, March 1 written day-first, is read month-first as January 3, and the tool returns, without any error,
the close of another date (27.75 instead of 25.20). A silent failure is worse than a loud one: that is why the schema
requires YYYY-MM-DD, validation rejects everything else and the result says which date was used.

3. Design, without code, the tools of an agent that prepares a daily summary of a portfolio: which tools you would
   give it, which you would not, and what the code would do instead of the model.

**Solution.**

Read-only tools, deterministic for a given date: the portfolio's positions as of the date, the day's closes and
returns (computed in code), the day's news per ticker with the publication time (to separate what came before and
after the close), and calendar events (earnings, dividends). All of them with the date as a required argument, so
that the report for any day can be reproduced and later information is not mixed in. I would not give it anything
that writes or sends orders: the agent writes, it does not trade (6.4.5). The code would produce the figures (P&L per
position, contributions, changes), the selection of what goes into the report (move thresholds, materiality) and the
final verification: every figure in the text in the trace, every quoted headline existing. The model only writes the
summary from an already computed table and, at most, chooses which news items to explain. With that design, a small
model can do the job; with free-form ReAct over low-level tools, not even a large one is reliable every day.

## Self-assessment

1. In an API with tool calling, who executes the function `precio(ticker, fecha)`?

   - A. The model, on the provider's servers
   - B. The program that calls the API: the model only returns the tool name and the arguments in JSON, and waits for the result in the next message
   - C. Nobody: the model makes up the result
   - D. The user's browser

   *Answer B: The program that calls the API: the model only returns the tool name and the arguments in JSON, and waits for the result in the next message.* With client-defined tools, the model's response carries a `tool_use` block (Anthropic) or a `function_call` item (OpenAI) with the name and the arguments. The program executes it and returns a `tool_result` or a `function_call_output`. That is why the program can validate, limit and audit every call.

2. The local LLM requests both closes correctly (26.19 and 27.88) and answers that the change “fue de 27.88%” (was 27.88%). Which guardrail catches it?

   - A. The step limit
   - B. Pydantic validation of the arguments
   - C. Repeated-call detection
   - D. A final-answer verifier that requires every percentage to come from a call to the calculator (or to a tool that computes) and every figure from some result

   *Answer D: A final-answer verifier that requires every percentage to come from a call to the calculator (or to a tool that computes) and every figure from some result.* The arguments were valid and there was no loop: the error is in the final answer, which copies a close as if it were the percentage. Only a check of the answer against the tool trace catches it.

3. What is the point of returning a validation error to the model (“fecha: String should match pattern…”) instead of stopping the agent?

   - A. So the model can correct the arguments in the next step, as the simulated invented-arguments scenario does
   - B. To spend fewer tokens
   - C. Because APIs do not allow stopping an agent
   - D. It is pointless: the model never corrects itself

   *Answer A: So the model can correct the arguments in the next step, as the simulated invented-arguments scenario does.* An informative error is one more observation of the ReAct loop. Capable models usually fix the format when they see it; a small one may not, which is why the error is counted and there is a step limit. What is never done is running the tool with unvalidated arguments.

4. In the loop scenario, with no step limit, the agent repeats the same call. Why does the cost grow faster than the number of steps?

   - A. Because each repeated call costs twice as much
   - B. Because the tool takes longer each time
   - C. Because each request resends the whole previous conversation: the tokens sent per step grow linearly and the total grows quadratically
   - D. It does not grow: the cost is constant

   *Answer C: Because each request resends the whole previous conversation: the tokens sent per step grow linearly and the total grows quadratically.* APIs are stateless: each step sends the complete history, with all the previous calls and results. With 30 steps many times more tokens are sent than with 6. The step limit and repetition detection are also a spending limit.

5. For “by what percentage did BUSI's close change between two dates?”, which design is more reliable with a small model?

   - A. Give it only a calculator and trust it to do the math
   - B. Give it a `variacion(ticker, desde, hasta)` tool that does the calculation in code, or a fixed plan in which the model only extracts the parameters
   - C. Ask it to think step by step without tools
   - D. Use a high temperature so it explores

   *Answer B: Give it a `variacion(ticker, desde, hasta)` tool that does the calculation in code, or a fixed plan in which the model only extracts the parameters.* The less the model has to chain, the fewer points of failure. A higher-level tool turns three calls and a calculation into one, and the arithmetic is done by code. It is planning fixed by the programmer for a known task.

## Key points

- Tool calling: the model emits structured requests (name and JSON arguments); the program validates them, executes them and returns the result. Nothing runs without going through the code.
- ReAct alternates reasoning, action (tool call) and observation (result) until it answers; planning first and executing afterwards is the alternative when the task is known.
- Minimum guardrails for the loop: argument validation with a schema, errors returned as observations, repetition detection, a step limit and verification of the final answer against the trace.
- The local 0.5B LLM formulates simple calls well, but ignores errors and results when writing: it invents a price for a nonexistent ticker and confuses a close with a percentage.
- The OpenAI shapes (`function_call` and `function_call_output`) and the Anthropic ones (`tool_use` and `tool_result`) are equivalent; the same loop works for both with an adapter, and it is tested without network access against simulators.

## Resources

### Research

- [Yao et al. (2022): ReAct, Synergizing Reasoning and Acting in Language Models (arXiv:2210.03629)](https://arxiv.org/abs/2210.03629) · arxiv.org
- [Schick et al. (2023): Toolformer, Language Models Can Teach Themselves to Use Tools (arXiv:2302.04761)](https://arxiv.org/abs/2302.04761) · arxiv.org

### Documentation

- [Anthropic: tool use with Claude (tool_use, tool_result)](https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview) · platform.claude.com
- [OpenAI: function calling (Responses API)](https://developers.openai.com/api/docs/guides/function-calling) · developers.openai.com
- [Hugging Face Transformers: tools in chat templates](https://huggingface.co/docs/transformers/main/en/chat_extras) · huggingface.co
- [Qwen: function calling (Hermes-style tool template and its limits)](https://qwen.readthedocs.io/en/latest/framework/function_call.html) · qwen.readthedocs.io

### Data

- [Level 6 data: news corpus (Parquet)](https://pythonparatrading.com/curso/datos/nivel-6/noticias6_corpus.parquet) · Download · noticias6_corpus.parquet

Previous lesson: [6.3.3 · RAG over reports, prospectuses and KIDs, and how to evaluate it](https://pythonparatrading.com/en/python-trading-course/level-6/rag-on-financial-reports-and-evaluation/)

Next lesson: [6.4.2 · Agent frameworks and MCP](https://pythonparatrading.com/en/python-trading-course/level-6/langgraph-llamaindex-agent-sdks-and-mcp/)
