> labs.luisguisado.cloud
Intermedio~40 min·por Luis Guisado·actualizado 5 de mayo de 2026

Agente con tools: del chatbot al agente que actúa

Le das al agente del Lab 2 la capacidad de leer y escribir archivos. El modelo decide cuándo invocar cada tool, tu código las ejecuta. Aprendes el loop de tool calling y la lista blanca como superficie de seguridad.

Costo estimado: free

Aparece en: Agentic AI desde cero para Backend y Cloud Developers

Por qué este lab existe

El TechnicalExplainerAgent del Lab 2 funciona para preguntas conceptuales. Probemos algo que dependa de un archivo concreto del proyecto:

agent = TechnicalExplainerAgent()
print(agent.run("¿Cuántas líneas tiene agent.py?"))

Lo que ves (la respuesta exacta varía entre corridas):

agent.py probablemente tiene entre 25 y 35 líneas, dependiendo de tu implementación...

El número es inventado. El modelo nunca abrió agent.py. Solo conoce lo que le pasas en el prompt: el system message y la tarea. Si la respuesta exige inspeccionar un archivo, escribir output a disco o calcular algo en código, el modelo improvisa.

En este lab le das al agente tools, funciones Python que el modelo puede invocar cuando las necesite. Después del lab, el agente puede leer agent.py, contar las líneas y responder con el número exacto.

Duración: ~40 min · Nivel: Intermediate · Costo: gratis (free tier de Groq).


Lo que vas a tener al terminar

  1. Un archivo tools.py con dos funciones (read_file, save_file) y sus JSON schemas, más una guarda de path traversal.
  2. Un agent.py modificado con un loop que ejecuta las tools cuando el modelo las invoca.
  3. Una corrida exitosa de python main.py donde el agente lee agent.py y guarda un resumen en summary.md.
  4. Trazabilidad de cada corrida: agent.last_tool_calls muestra qué tools se invocaron, en qué orden y con qué argumentos.

Qué vas a aprender

  • Definir una tool: una función Python más un JSON Schema que la describe al modelo.
  • Implementar el loop de tool calling: request, tool_calls, ejecución, respuesta final.
  • Auditar las invocaciones de un agente con un atributo last_tool_calls análogo a last_usage.

Conceptos clave

Tool calling

Qué es: una tool (también llamada function en inglés o herramienta en español; los tres nombres refieren al mismo concepto) es una función Python que el agente puede invocar durante una llamada al modelo. El modelo no la ejecuta directamente: recibe la lista de tools disponibles con sus JSON schemas y, cuando una pregunta requiere una de ellas, devuelve un objeto tool_call con el nombre y los argumentos a usar. Tu código ejecuta la función real y le devuelve el resultado al modelo, que lo usa para construir la respuesta final.

Mini-ejemplo:

def read_file(path: str) -> str:
    with open(path) as f:
        return f.read()

# El schema le dice al modelo qué tool existe y qué argumentos espera.
read_file_schema = {
    "type": "function",
    "function": {
        "name": "read_file",
        "description": "Read a file from the local filesystem.",
        "parameters": {
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
        },
    },
}

El loop de tool calling

Qué es: una sola llamada al modelo no basta cuando hay tools. La interacción es iterativa: el modelo decide invocar una tool, tu código la ejecuta, mandas el resultado de vuelta al modelo, y el modelo decide si necesita otra tool o ya puede responder. Si la tarea exige varias tools en serie (leer un archivo, después escribir otro), el loop se ejecuta varias veces antes de la respuesta final.

Mini-ejemplo:

while True:
    response = client.chat.completions.create(messages=messages, tools=schemas)
    msg = response.choices[0].message
    if not msg.tool_calls:
        return msg.content    # respuesta final, salir del loop
    for call in msg.tool_calls:
        result = TOOLS[call.function.name](**json.loads(call.function.arguments))
        messages.append(tool_result_message(call.id, result))

Para profundizar: el formato de tools que usamos viene de OpenAI · Function Calling. Groq lo implementa idéntico, así que el código que escribes en este lab corre sin cambios contra OpenAI cambiando base_url y api_key.


Mapa visual

Una corrida con la tarea “lee agent.py y guarda un resumen en summary.md” produce tres iteraciones del loop:

   agent.run("Lee agent.py y guarda un resumen en summary.md")


   ┌─ ITERACIÓN 1 ──────────────────────────────┐
   │ messages = [system, user]                  │
   │ POST /chat/completions con tools           │
   │ ◀─ response.tool_calls = [read_file]       │
   │ ejecutar read_file("agent.py")             │
   │ messages += [assistant_msg, tool_result]   │
   └────────────────────────────────────────────┘


   ┌─ ITERACIÓN 2 ──────────────────────────────┐
   │ POST /chat/completions con messages++      │
   │ ◀─ response.tool_calls = [save_file]       │
   │ ejecutar save_file("summary.md", "...")    │
   │ messages += [assistant_msg, tool_result]   │
   └────────────────────────────────────────────┘


   ┌─ ITERACIÓN 3 ──────────────────────────────┐
   │ POST /chat/completions con messages++      │
   │ ◀─ response.tool_calls = []                │
   │ return response.content                    │
   └────────────────────────────────────────────┘


   "Listo, guardé el resumen en summary.md"

Cada iteración es una llamada HTTP al modelo. El modelo no tiene memoria entre iteraciones; el array messages lleva el historial completo y crece en cada paso.


Punto de partida

Antes de modificar nada, verifica que el Lab 2 esté funcionando.

cd agentic-ai-lab
source .venv/bin/activate
python main.py

Respuesta esperada: ves tres respuestas con sus líneas de tokens, igual que al final del Lab 2.

Si el script falla, regresa al Lab 2 y verifica que agent.py tenga last_usage y que main.py lo lea correctamente.


Paso 1. Definir las tools en tools.py

Archivo: tools.py (NUEVO)

import os

# Restringe lectura/escritura al directorio del proyecto.
ALLOWED_DIR = os.path.abspath(".")


def _resolve(path: str) -> str:
    """Resuelve `path` dentro de ALLOWED_DIR. Rechaza paths que escapen."""
    abs_path = os.path.abspath(path)
    if os.path.commonpath([abs_path, ALLOWED_DIR]) != ALLOWED_DIR:
        raise ValueError(f"path {path!r} resuelve fuera de {ALLOWED_DIR}")
    return abs_path


def read_file(path: str) -> str:
    with open(_resolve(path), "r", encoding="utf-8") as f:
        return f.read()


def save_file(path: str, content: str) -> str:
    with open(_resolve(path), "w", encoding="utf-8") as f:
        f.write(content)
    return f"saved {len(content)} chars to {path}"


# Lista blanca: el agente solo puede invocar funciones que estén acá.
TOOLS = {
    "read_file": read_file,
    "save_file": save_file,
}


# JSON schemas que el modelo recibe en cada llamada para saber qué tools existen.
TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "Read a file from the local filesystem and return its content.",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Path relative to the project directory.",
                    },
                },
                "required": ["path"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "save_file",
            "description": "Write content to a file. Overwrites if the file exists.",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Destination path relative to the project directory.",
                    },
                    "content": {
                        "type": "string",
                        "description": "Content to write to the file.",
                    },
                },
                "required": ["path", "content"],
            },
        },
    },
]

Tres cosas para notar:

  1. TOOLS es la lista blanca. Si el modelo inventa un nombre, falla con KeyError antes de ejecutar nada.
  2. TOOL_SCHEMAS es lo que viaja al modelo en cada request. La description de cada schema es la forma como le explicas al modelo qué hace cada tool y cuándo usarla.
  3. _resolve() valida que cualquier operación sobre archivos quede dentro del directorio del proyecto. Sin esa guarda, el agente podría leer /etc/passwd o sobrescribir archivos del sistema si el modelo se inventa un path.

Probar:

python -c "from tools import TOOLS, TOOL_SCHEMAS; print(list(TOOLS.keys()))"

Respuesta esperada:

['read_file', 'save_file']

Paso 2. Modificar agent.py para soportar tools

Archivo: agent.py (REESCRITO)

import json
import os
from dotenv import load_dotenv
from openai import OpenAI

from tools import TOOLS, TOOL_SCHEMAS


class TechnicalExplainerAgent:
    role = "Experto en AWS y cloud computing"
    goal = "Explicar conceptos técnicos de cloud a backend developers con ejemplos claros"
    instructions = (
        f"Eres un {role}. "
        f"Tu objetivo: {goal}. "
        "Cuando una pregunta requiera leer o escribir archivos del proyecto, usa las tools disponibles. "
        "Responde en español. Sé conciso: máximo 5 oraciones."
    )

    def __init__(self):
        load_dotenv()
        self._client = OpenAI(
            base_url="https://api.groq.com/openai/v1",
            api_key=os.environ["GROQ_API_KEY"],
        )
        self.last_usage = None
        self.last_tool_calls = []

    def run(self, task: str) -> str:
        self.last_tool_calls = []
        messages = [
            {"role": "system", "content": self.instructions},
            {"role": "user", "content": task},
        ]

        while True:
            response = self._client.chat.completions.create(
                model="llama-3.3-70b-versatile",
                messages=messages,
                tools=TOOL_SCHEMAS,
                tool_choice="auto",
                temperature=0.3,
            )
            self.last_usage = response.usage
            message = response.choices[0].message

            if not message.tool_calls:
                return message.content

            # Apendar el assistant message (con tool_calls) al historial.
            messages.append({
                "role": "assistant",
                "content": message.content or "",
                "tool_calls": [
                    {
                        "id": tc.id,
                        "type": "function",
                        "function": {
                            "name": tc.function.name,
                            "arguments": tc.function.arguments,
                        },
                    }
                    for tc in message.tool_calls
                ],
            })

            # Ejecutar cada tool y apendar su resultado.
            for call in message.tool_calls:
                name = call.function.name
                args = json.loads(call.function.arguments)
                result = TOOLS[name](**args)
                self.last_tool_calls.append({"name": name, "args": args})
                messages.append({
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": str(result),
                })

Cambios respecto al Lab 2:

  1. Importas TOOLS y TOOL_SCHEMAS de tools.py. La instrucción del system prompt incluye que use las tools cuando aplique.
  2. run() ya no es una sola llamada al modelo; ahora corre un loop. En cada iteración, si el modelo devolvió tool_calls, ejecutas las tools, apendas los resultados al historial y vuelves a llamar. El loop termina cuando el modelo responde sin tool_calls.
  3. last_tool_calls registra cada invocación. El llamador puede inspeccionar qué se ejecutó después de cada run(), igual que con last_usage.

Probar:

python -c "from agent import TechnicalExplainerAgent; TechnicalExplainerAgent(); print('OK')"

Respuesta esperada:

OK

Paso 3. Probar con una tarea que requiere ambas tools

Archivo: main.py (REESCRITO)

from agent import TechnicalExplainerAgent

agent = TechnicalExplainerAgent()

tarea = (
    "Lee el archivo agent.py del proyecto. "
    "Después guárdame un resumen breve de qué hace en summary.md."
)

respuesta = agent.run(tarea)

print("Respuesta del agente:")
print(respuesta)

print("\nTools invocadas en orden:")
for call in agent.last_tool_calls:
    args_preview = {
        k: (v[:60] + "...") if isinstance(v, str) and len(v) > 60 else v
        for k, v in call["args"].items()
    }
    print(f"  - {call['name']}({args_preview})")

u = agent.last_usage
print(f"\nTokens (última iteración): {u.total_tokens}")

Probar:

python main.py

Respuesta esperada (varía entre corridas):

Respuesta del agente:
Listo. Leí agent.py y guardé un resumen en summary.md.

Tools invocadas en orden:
  - read_file({'path': 'agent.py'})
  - save_file({'path': 'summary.md', 'content': 'TechnicalExplainerAgent es una clase...'})

Tokens (última iteración): 412

Verifica que summary.md exista con contenido:

cat summary.md

Vas a ver el resumen que generó el modelo después de leer agent.py.


Errores comunes

Error 1. tool_call.function.arguments es un string JSON, no un dict

Síntoma: al ejecutar TOOLS[name](**args) recibes TypeError: argument of type 'str' is not a mapping o un error parecido.

Causa raíz: el campo arguments que devuelve el modelo es un STRING (JSON serializado), no un dict Python. Pasarlo directo a **args falla porque ** espera un mapping.

Corrección: parsear con json.loads() antes:

args = json.loads(call.function.arguments)
result = TOOLS[name](**args)

Error 2. Olvidar apendar el assistant message antes de los tool results

Síntoma: el modelo entra en loop infinito invocando la misma tool, o el servidor responde con "messages with role 'tool' must follow a message with 'tool_calls'".

Causa raíz: el protocolo OpenAI/Groq exige este orden en el array messages: primero el assistant message con tool_calls, después los tool messages con los resultados. Si saltas el assistant y solo agregas los tool results, el modelo no entiende qué se ejecutó.

Corrección: mantén el orden del Paso 2:

messages.append(assistant_msg)        # 1. el mensaje con tool_calls
for call in message.tool_calls:
    ...
    messages.append(tool_result_msg)  # 2. cada tool result

Buenas prácticas

  • Lista blanca de tools. El agente solo invoca funciones que estén en el dict TOOLS. Si el modelo devuelve un nombre que no existe, falla con KeyError antes de ejecutar código. Nunca uses eval(name) o globals()[name] para resolver el nombre: eso convierte cualquier alucinación del modelo en una vulnerabilidad de ejecución arbitraria. Ref: OWASP Top 10 for LLM Applications · LLM01.
  • Validar argumentos antes de ejecutar. En tools.py, _resolve() valida que el path no escape de ALLOWED_DIR. Las tools son superficie de ataque: si el modelo inventa un path, no debe poder leer /etc/passwd ni sobrescribir archivos arbitrarios. Ref: principio de menor privilegio (Saltzer & Schroeder, The Protection of Information in Computer Systems, 1975).
  • Trazabilidad expuesta. last_tool_calls registra qué tools se invocaron, en qué orden y con qué argumentos. Sin trazabilidad no puedes responder “por qué el agente hizo X” en debugging ni en auditoría. Mismo patrón que last_usage del Lab 2: el agente registra, el llamador decide qué hacer con el registro.

Estado del proyecto al finalizar

agentic-ai-lab/
├── .venv/
├── .env
├── .gitignore
├── call_llm.py          ← del Lab 1, sin cambios
├── tools.py             ← NUEVO (read_file, save_file y sus schemas)
├── agent.py             ← REESCRITO (loop de tool calling)
├── main.py              ← REESCRITO (demo con ambas tools en serie)
└── summary.md           ← generado por el agente al correr main.py

Checklist:

  • python main.py corre sin errores y produce un summary.md no vacío.
  • agent.last_tool_calls lista las dos invocaciones (read_file, después save_file).
  • El agente solo puede invocar funciones que estén en el dict TOOLS de tools.py.
  • _resolve() rechaza paths que apunten fuera del directorio del proyecto.

¿Qué viene en el Lab 4?

Tu agente lee, escribe y razona. Funciona bien para tareas individuales. Pero si el caso de uso requiere investigar un tema, redactar una explicación y después revisarla, intentar meter los tres roles en una sola clase mezcla las instrucciones y degrada la calidad de cada uno. En el Lab 4 vas a usar CrewAI para coordinar varios agentes con roles distintos, cada uno con sus propias tools, y un orquestador que ejecuta las tareas en orden.


Referencias

Documentación oficial

Lecturas recomendadas

  • OWASP Top 10 for LLM Applications: contexto sobre por qué la lista blanca de tools no es opcional.
  • Saltzer, J. & Schroeder, M. The Protection of Information in Computer Systems (1975): origen del principio de menor privilegio.

Repositorios y librerías

  • openai-python: el SDK oficial soporta el formato de tool calling que usamos en este lab; el código corre contra OpenAI cambiando base_url y api_key.