Lesson 6.4.1 · 70 min

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.

Fig. — Tool-loop traces: four scenarios of a simulated model (identical with the OpenAI and Anthropic APIs) and three runs of the local LLM. Circle: valid call; triangle: error returned to the model; square: final answer; cross: cut by the step limit
01/06
Module
6.4 · AI agents for trading
Duration
70 min
Updated
Level 6 lessons
  1. 6.1 · Classical and modern financial NLP

    1. 6.1.1 Tokenization, embeddings, financial dictionaries and FinBERT
    2. 6.1.2 Text sources: EDGAR, CNMV, transcripts and social media
    3. 6.1.3 Event studies with text signals, timestamp alignment and topic modeling
  2. 6.2 · LLMs for research and trading

    1. 6.2.1 How an LLM works: from the next token to the API
    2. 6.2.2 Structured prompting: JSON you can validate
    3. 6.2.3 Guidance, KPIs and tone: what an LLM extracts well
    4. 6.2.4 Lightweight fine-tuning for financial tasks: LoRA and QLoRA
    5. 6.2.5 Look-ahead bias in LLMs: the model that already knows the future
  3. 6.3 · Financial RAG

    1. 6.3.1 Embeddings and vector databases: FAISS, Chroma, pgvector and Qdrant
    2. 6.3.2 Chunking financial documents: tables, footnotes and XBRL
    3. 6.3.3 RAG over reports, prospectuses and KIDs, and how to evaluate it
  4. 6.4 · AI agents for trading

    1. 6.4.1 Tool-calling agents: tool calling, ReAct and planning
    2. 6.4.2 Agent frameworks and MCP
    3. 6.4.3 TradingAgents-style multi-agent architectures
    4. 6.4.4 Evidence and limits of agents
    5. 6.4.5 Guardrails for agents: the agent proposes, the engine decides
    6. 6.4.6 Research agents: hypotheses, alpha mining and review
  5. 6.5 · AI as the quant's copilot

    1. 6.5.1 Coding assistants with a test-first workflow
    2. 6.5.2 Automated review of backtests and code migrations
    3. 6.5.3 Risks of AI-generated code
  6. 6.6 · Big data for trading

    1. 6.6.1 When data becomes “big”: ticks, order books, options and universes
    2. 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
    3. 6.6.3 Dask, Spark and Ray: from one machine to a cluster
    4. 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
    5. 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
  7. 6.7 · Alternative data

    1. 6.7.1 Overview and size of the alternative data market
    2. 6.7.2 Types of alternative data: what each one measures
    3. 6.7.3 Responsible web scraping
    4. 6.7.4 Alternative data vendors and due diligence
    5. 6.7.5 From alternative data to a trading signal
    6. 6.7.6 Satellite imagery, computer vision and supply chains

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.

yesnoQuestionLLMAsks for a tool?Validate argumentsExecute in codeObservationFinal answerVerify
Mermaid source
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.

IN [01]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"}))
OUT [01]Tools, schemas and validated execution
{'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.

IN [02]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")])
OUT [02]The tools inside Qwen's prompt
# 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.

IN [03]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)
OUT [03]ReAct loop with Qwen2.5-0.5B: two tasks
¿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.

IN [04]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")
OUT [04]Final-answer verifier
¿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.
QuestionLLM extracts dataValidate schemaCalculation in codeLLM writesVerify
Mermaid source
flowchart TB
  P[Question] --> E[LLM extracts data]
  E --> V[Validate schema]
  V --> H[Calculation in code]
  H --> R[LLM writes]
  R --> C[Verify]
IN [05]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")
OUT [05]The same question with a tool that computes
  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 callitem {"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 inputblock {"type": "tool_result", "tool_use_id", "content"} in a user message (is_error for errors)
Several calls at oncepossible (parallel_tool_calls)possible; all the results in a single message

Shapes according to each provider's documentation, accessed on October 23, 20261Note 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.. The real code, which is not run here because it needs keys and network access:

IN [06]python
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)}]
IN [07]python
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:

ScenarioWhat 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
IN [08]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])
OUT [08]A model script and the two simulators (OpenAI and Anthropic)
[('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.

IN [09]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}")
OUT [09]One loop, two providers, four scenarios
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.
IN [10]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")
OUT [10]No step limit: what a loop costs
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

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
IN [11]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))
OUT [11]Quoted headlines that exist
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.

  1. 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
IN [12]python
for f in ["15/03/2024", "2024-03-15", "01/03/2024", "2024-03-01"]:
    print(f, precio("BUSI", f))
OUT [12]A malformed date without validation
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.

  1. 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.

Footnotes

  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. ↩

Each answer is folded under its question

Self-assessment

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

    • AThe model, on the provider's servers
    • BThe 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
    • CNobody: the model makes up the result
    • DThe user's browser
    Show answer

    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?

    • AThe step limit
    • BPydantic validation of the arguments
    • CRepeated-call detection
    • DA 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
    Show answer

    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?

    • ASo the model can correct the arguments in the next step, as the simulated invented-arguments scenario does
    • BTo spend fewer tokens
    • CBecause APIs do not allow stopping an agent
    • DIt is pointless: the model never corrects itself
    Show answer

    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?

    • ABecause each repeated call costs twice as much
    • BBecause the tool takes longer each time
    • CBecause each request resends the whole previous conversation: the tokens sent per step grow linearly and the total grows quadratically
    • DIt does not grow: the cost is constant
    Show answer

    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?

    • AGive it only a calculator and trust it to do the math
    • BGive 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
    • CAsk it to think step by step without tools
    • DUse a high temperature so it explores
    Show answer

    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

Data