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
- Un archivo
tools.pycon dos funciones (read_file,save_file) y sus JSON schemas, más una guarda de path traversal. - Un
agent.pymodificado con un loop que ejecuta las tools cuando el modelo las invoca. - Una corrida exitosa de
python main.pydonde el agente leeagent.pyy guarda un resumen ensummary.md. - Trazabilidad de cada corrida:
agent.last_tool_callsmuestra 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_callsanálogo alast_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_urlyapi_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:
TOOLSes la lista blanca. Si el modelo inventa un nombre, falla conKeyErrorantes de ejecutar nada.TOOL_SCHEMASes lo que viaja al modelo en cada request. Ladescriptionde cada schema es la forma como le explicas al modelo qué hace cada tool y cuándo usarla._resolve()valida que cualquier operación sobre archivos quede dentro del directorio del proyecto. Sin esa guarda, el agente podría leer/etc/passwdo 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:
- Importas
TOOLSyTOOL_SCHEMASdetools.py. La instrucción del system prompt incluye que use las tools cuando aplique. 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.last_tool_callsregistra cada invocación. El llamador puede inspeccionar qué se ejecutó después de cadarun(), igual que conlast_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 conKeyErrorantes de ejecutar código. Nunca useseval(name)oglobals()[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 deALLOWED_DIR. Las tools son superficie de ataque: si el modelo inventa un path, no debe poder leer/etc/passwdni sobrescribir archivos arbitrarios. Ref: principio de menor privilegio (Saltzer & Schroeder, The Protection of Information in Computer Systems, 1975). - Trazabilidad expuesta.
last_tool_callsregistra 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 quelast_usagedel 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.pycorre sin errores y produce unsummary.mdno vacío. -
agent.last_tool_callslista las dos invocaciones (read_file, despuéssave_file). - El agente solo puede invocar funciones que estén en el dict
TOOLSdetools.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_urlyapi_key.