El problema
Tu primer agente va a intentar hacerlo todo.
Va a navegar por la web, escribir en bases de datos, “optimizar” configs y, en general, comportarse como un becario con acceso root.
No empieces ahí.
Empieza con un tool, read-only, con budgets y logs.
Por qué importa en sistemas reales
La primera vez que shipeas tool calling vas a descubrir:
- timeouts que faltan
- que no tenías idempotencia
- que tus logs no sirven
Mejor descubrirlo con un tool que con doce.
El playbook (20 minutos, sin heroicidades)
- Elige un único tool read-only (
web.searchokb.search). - Añade budgets: steps + time (+ $ si puedes).
- Loggea cada tool call (nombre, hash de args, duración, estado).
- Detecta loops por args repetidos.
- Todavía no añadas tools de “send” o “write”.
Qué significa de verdad “un solo tool”
“Un solo tool” no es “un tool más una salida de emergencia escondida”.
Significa:
- puedes señalar la allowlist y contarla con una mano
- no hay un tool genérico
http.requestcon acceso total a internet - no hay
run_shell“para depurar”
Si tu primer agente puede hacer fetch de URLs arbitrarias, ya te saltaste la fase segura.
Paso 0: elige una tarea que no te arruine
Buenas primeras tareas:
- “encuentra 5 docs relevantes en nuestra KB”
- “resume incidentes recientes del directorio interno de postmortems”
- “redacta una respuesta usando plantillas conocidas”
Malas primeras tareas:
- “navega por la web hasta estar seguro”
- “arregla la config de prod”
- “ejecuta comandos en servidores”
Tu primer agente debería ser aburrido y barato.
Paso 1: budgets (lo que convierte un loop en sistema)
Los budgets no son un “nice to have”. Son la diferencia entre:
- una request que se detiene
- una request que sigue cobrando
Usamos como mínimo:
- max steps
- max seconds
Si puedes estimar el gasto en $, añádelo pronto. Se paga solo.
Paso 2: loggea el action trace
No necesitas un sistema de tracing perfecto el día 1. Necesitas lo suficiente para responder:
- ¿qué tools llamó?
- ¿con qué argumentos?
- ¿cuánto duró cada call?
- ¿por qué se detuvo?
Si no puedes responder eso, no puedes shippear esto.
Paso 2.5: haz los tools deterministas (o no podrás depurar)
Tu agente es un loop. Los loops ya son bastante duros. Ahora añade tools que devuelven resultados distintos en cada run (timeouts, APIs flaky, páginas que cambian). Enhorabuena: has construido un sistema no determinista y vas a depurarlo a las 03:00.
El truco que usamos pronto: record/replay. Para un run, registra:
- nombre del tool
- hash de args
- respuesta (o error)
Luego puedes reproducir el mismo “mundo” para probar:
- cambios de prompt
- cambios de modelo
- ajustes del loop guard
Código conceptual mínimo:
class TapeTools(SafeTools):
def __init__(self, allow: set[str], impl: dict, tape: list[dict] | None = None):
super().__init__(allow=allow, impl=impl)
self.tape = tape if tape is not None else []
def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
try:
out = super().call(name, args=args, request_id=request_id)
self.tape.append({"tool": name, "args": args, "ok": True, "out": out})
return out
except Exception as e:
self.tape.append({"tool": name, "args": args, "ok": False, "err": str(e)})
raise
def replay(tape: list[dict]):
# Replace real tools with deterministic playback.
i = 0
def impl(name: str, **args):
nonlocal i
item = tape[i]
i += 1
assert item["tool"] == name
if not item["ok"]:
raise RuntimeError(item["err"])
return item["out"]
return implexport class TapeTools extends SafeTools {
constructor({ allow, impl, tape }) {
super({ allow, impl });
this.tape = tape || [];
}
async call(name, { args, requestId }) {
try {
const out = await super.call(name, { args, requestId });
this.tape.push({ tool: name, args, ok: true, out });
return out;
} catch (e) {
this.tape.push({
tool: name,
args,
ok: false,
err: String(e && e.message ? e.message : e),
});
throw e;
}
}
}
export function replay(tape) {
let i = 0;
return async function call(name, args) {
const item = tape[i];
i += 1;
if (!item || item.tool !== name) throw new Error("tape mismatch");
if (!item.ok) throw new Error(item.err);
return item.out;
};
}¿Es glamuroso? No. ¿Te permite correr una “golden suite” de 20 tareas antes de shippear? Sí. Así evitas enterarte de regresiones por clientes reales.
Código (mínimo, pero con forma de producción)
from dataclasses import dataclass
import time
from typing import Any
@dataclass(frozen=True)
class Budget:
max_steps: int = 12
max_seconds: int = 20
class Tools:
def __init__(self):
self.allow = {"web.search"}
def call(self, name: str, *, args: dict[str, Any]) -> Any:
if name not in self.allow:
raise RuntimeError(f"tool not allowed: {name}")
return tool_impl(name, args=args) # (pseudo)
def first_agent(question: str, *, tools: Tools, budget: Budget) -> str:
started = time.time()
last = None
for step in range(budget.max_steps):
if time.time() - started > budget.max_seconds:
return "stopped: time budget exceeded"
action = llm_pick_action(question) # (pseudo): either {"tool":..., "args":...} or {"finish":...}
if action.get("finish"):
return action["finish"]
key = f"{action['tool']}:{action['args']}"
if key == last:
return "stopped: loop guard (same call twice)"
last = key
obs = tools.call(action["tool"], args=action["args"])
question = update_state(question, action, obs) # (pseudo)
return "stopped: step budget exceeded"export class Tools {
constructor() {
this.allow = new Set(["web.search"]);
}
async call(name, { args }) {
if (!this.allow.has(name)) throw new Error("tool not allowed: " + name);
return toolImpl(name, { args }); // (pseudo)
}
}
export async function firstAgent(question, { tools, budget }) {
const started = Date.now();
let last = null;
for (let step = 0; step < budget.max_steps; step++) {
if ((Date.now() - started) / 1000 > budget.max_seconds) {
return "stopped: time budget exceeded";
}
const action = await llmPickAction(question); // (pseudo): { tool, args } or { finish }
if (action.finish) return action.finish;
const key = String(action.tool) + ":" + JSON.stringify(action.args);
if (key === last) return "stopped: loop guard (same call twice)";
last = key;
const obs = await tools.call(action.tool, { args: action.args });
question = updateState(question, action, obs); // (pseudo)
}
return "stopped: step budget exceeded";
}Hazlo real (un skeleton pequeño y ejecutable)
El ejemplo mínimo de arriba es intencionalmente corto. Aquí tienes un “single file” skeleton un poco más completo, que parece algo que podrías poner detrás de una API:
from dataclasses import dataclass
import hashlib
import json
import time
import uuid
from typing import Any, Callable
@dataclass(frozen=True)
class Budget:
max_steps: int = 12
max_seconds: int = 20
def args_hash(args: dict[str, Any]) -> str:
raw = json.dumps(args, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:12]
class LoopGuard(RuntimeError):
pass
class Monitor:
def __init__(self, *, max_repeat: int = 2):
self.max_repeat = max_repeat
self.counts: dict[str, int] = {}
def mark(self, tool: str, args: dict[str, Any]) -> None:
key = f"{tool}:{args_hash(args)}"
self.counts[key] = self.counts.get(key, 0) + 1
if self.counts[key] >= self.max_repeat:
raise LoopGuard(f"loop guard: {key} repeated {self.counts[key]}x")
class SafeTools:
def __init__(self, allow: set[str], impl: dict[str, Callable[..., Any]]):
self.allow = allow
self.impl = impl
def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
if name not in self.allow:
raise RuntimeError(f"[{request_id}] tool not allowed: {name}")
started = time.time()
try:
return self.impl[name](**args)
finally:
ms = int((time.time() - started) * 1000)
print(f"[{request_id}] tool={name} ms={ms} args_hash={args_hash(args)}")
def run_first_agent(question: str, *, tools: SafeTools, budget: Budget) -> str:
request_id = uuid.uuid4().hex
started = time.time()
monitor = Monitor(max_repeat=2)
state = {"question": question, "notes": []}
for step in range(budget.max_steps):
if time.time() - started > budget.max_seconds:
return f"[{request_id}] stopped: time budget"
action = llm_pick_action(state) # (pseudo)
if action.get("finish"):
return action["finish"]
tool = action["tool"]
args = action["args"]
monitor.mark(tool, args)
obs = tools.call(tool, args=args, request_id=request_id)
state = update_state(state, action, obs) # (pseudo)
return f"[{request_id}] stopped: step budget"import crypto from "node:crypto";
function stableStringify(obj) {
if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
if (Array.isArray(obj)) return "[" + obj.map(stableStringify).join(",") + "]";
const keys = Object.keys(obj).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
}
export function argsHash(args) {
return crypto.createHash("sha256").update(stableStringify(args)).digest("hex").slice(0, 12);
}
export class LoopGuard extends Error {}
export class Monitor {
constructor({ maxRepeat = 2 } = {}) {
this.maxRepeat = maxRepeat;
this.counts = new Map();
}
mark(tool, args) {
const key = tool + ":" + argsHash(args);
const next = (this.counts.get(key) || 0) + 1;
this.counts.set(key, next);
if (next >= this.maxRepeat) throw new LoopGuard("loop guard: " + key + " repeated " + next + "x");
}
}
export class SafeTools {
constructor({ allow, impl }) {
this.allow = allow;
this.impl = impl;
}
async call(name, { args, requestId }) {
if (!this.allow.has(name)) throw new Error("[" + requestId + "] tool not allowed: " + name);
const started = Date.now();
try {
return await this.impl[name](args);
} finally {
const ms = Date.now() - started;
console.log("[" + requestId + "] tool=" + name + " ms=" + ms + " args_hash=" + argsHash(args));
}
}
}
export async function runFirstAgent(question, { tools, budget }) {
const requestId = crypto.randomUUID().replace(/-/g, "");
const started = Date.now();
const monitor = new Monitor({ maxRepeat: 2 });
let state = { question, notes: [] };
for (let step = 0; step < budget.max_steps; step++) {
if ((Date.now() - started) / 1000 > budget.max_seconds) return "[" + requestId + "] stopped: time budget";
const action = await llmPickAction(state); // (pseudo)
if (action.finish) return action.finish;
const tool = action.tool;
const args = action.args;
monitor.mark(tool, args);
const obs = await tools.call(tool, { args, requestId });
state = updateState(state, action, obs); // (pseudo)
}
return "[" + requestId + "] stopped: step budget";
}Esto no es “el framework perfecto”. Es un loop pequeño y acotado con:
- allowlisted tools
- budgets
- loop guard
- basic logs
Es suficiente para empezar a aprender sin prender fuego producción.
Paso 3: una “tool policy” pequeña (incluso en read-only)
Incluso los tools read-only deberían estar allowlisted explícitamente. Te obliga a hacer visible la superficie.
Puedes extender la clase juguete Tools de arriba a algo operable:
- add args hashing
- add timing
- add error classification (retryable vs fatal)
No lo sobre‑ingenierices. Hazlo depurable.
Tool contracts (make the model play inside the lines)
The model will happily invent args:
- wrong field names
- huge strings
- weird nested objects
Si tu wrapper acepta “any dict”, tu runtime se convierte en la capa de validación. Eso significa que vas a depurar errores de validación en producción.
Empieza simple:
- define an args shape per tool
- validate types and bounds
- reject unknown fields
Ejemplo conceptual:
def validate_web_search(args: dict[str, Any]) -> dict[str, Any]:
q = str(args.get("q", "")).strip()
if not q or len(q) > 200:
raise ValueError("invalid q")
k = int(args.get("k", 5))
if k < 1 or k > 8:
raise ValueError("invalid k")
return {"q": q, "k": k}export function validateWebSearch(args) {
const q = String((args && args.q) || "").trim();
if (!q || q.length > 200) throw new Error("invalid q");
const k = Number((args && args.k) ?? 5);
if (!Number.isFinite(k) || k < 1 || k > 8) throw new Error("invalid k");
return { q, k };
}Esto parece tonto. También es lo que evita que tu agente llame a web.search con un prompt dump de 10.000 caracteres.
Y te da un error limpio que puedes tratar como fatal (no “reintentar para siempre”).
Paso 4: un rollout que no duele
Así es como shipeamos la primera versión:
- Solo interno (tú y un compañero)
- Read-only (sin write tools)
- Canary (un slice pequeño de tráfico)
- Kill switch (parada del operador)
Si te saltas el canary, la primera vez que loopee será con tu cliente más importante.
Paso 4.5: cancelación (deja de gastar cuando el usuario se va)
Si tienes UI, los usuarios abandonarán requests. Si no propagas la cancelación, el agente sigue corriendo igual. Sigue llamando tools. Sigue quemando tokens. Sigue generando output que nadie lee.
Haz de la cancelación un stop reason de primera clase:
- client disconnect → abort del run actual
- el abort debe propagarse a model calls y tool calls
- loggea
stop_reason = client_cancel
Incluso una implementación cutre es mejor que “seguir gastando hasta que muera el budget”.
Paso 5: métricas de éxito (para saber que mejora)
Elige 3 números y síguelos:
- completion rate (¿termina?)
- coste medio por run ($ o tokens o créditos de tools)
- p95 runtime
Si el completion rate es alto pero el coste explota, no construiste budgets bien. Si el coste es bajo pero el completion rate es terrible, probablemente tu contrato de tool es malo.
Dónde ejecutarlo (serverless vs workers)
Tu primer agente probablemente corra como una API route. Está bien… hasta que deja de estarlo.
Dos trampas comunes:
- cold starts: cold starts largos + un loop = mal UX
- time limits: las plataformas serverless tienen límites de ejecución; tu “budget de 90 segundos” puede no caber
A menudo lo separamos así:
- entra la request (rápido)
- el run del agente ocurre en un worker (con budgets)
- la UI hace poll / stream del progreso
Si eso suena a “demasiada arquitectura”, mantenlo simple: pon budgets lo bastante bajos como para que tu plataforma pueda parar el run de verdad. La forma más rápida de conseguir 500s es un loop largo dentro de un request handler sin guardrails.
Hardening v2 (sigue siendo pequeño, sigue siendo seguro)
Cuando la versión de “un tool” sea estable, esto es lo siguiente que añadimos:
Hash de args + dedupe
Si el agente llama el mismo tool con los mismos args repetidamente, párarlo. Esto captura los loops infinitos más fáciles.
Add per-tool budgets
Los budgets globales paran el run. Los budgets por tool evitan que una dependencia flaky se coma el run.
Example:
web.searchmax 3 calls
Add a kill switch
Incluso para un agente pequeño. Si corre en producción, quieres poder pararlo sin deploy.
Add a “stop reason”
Haz que el stop reason sea visible en logs y UI:
- time budget
- step budget
- loop detected
- tool denied
Esto evita que los usuarios machaquen refresh.
El primer write tool (cómo no liarla)
Cuando por fin añadas un write tool:
- make it a separate tool (don’t reuse read tool name)
- add idempotency keys
- require approval by default
- log an audit event for the intended write
Si te saltas la idempotencia, vas a shippear escrituras duplicadas. No es “quizá”. Es “cuándo”.
Errores típicos del primer agente (los hemos cometido)
- Deriva de “solo un tool más”. Empiezas con
web.searchy acabas conhttp.requesty un token admin. - Retries sin límites. “Manejas errores” y montas un loop infinito sin querer.
- Sin stop reason en la UI. La gente refresca porque no sabe qué pasó, y pagas dos veces.
- Loggear solo la respuesta final. Luego no puedes explicar por qué llamó al tool 17 veces.
Si evitas esas cuatro, ya vas por delante de la mayoría de “agent demos”. Y: ejecútalo con un tool falso que falle una vez. Si el loop sobrevive sin spam de retries, bien. Si se derrite, arréglalo ya. En serio, ya.
Fallo real
Hemos visto un “primer agente” en producción con 0 budgets. Recibió un prompt raro, empezó a navegar y se quedó atascado.
Resultado:
- el usuario esperó ~2 minutos
- el agente quemó dinero
- todo el mundo culpó al modelo
No fue el modelo. Fueron los budgets que faltaban.
Cuándo NO construir un agente (todavía)
- Si un script lo puede hacer, escribe el script.
- Si un workflow lo puede hacer, construye el workflow.
- Si no puedes añadir tool permissions + audit logs, no shipees tool calling.
Siguiente
- Empieza aquí: ¿Qué es un agente de IA?
- Patrón: Research agent
- Fallo: Bucle infinito