Human in the loop: tu agente no puede esperar en un await
El mensaje llegó a Slack a las 19:42: «El agente quiere reembolsar 38 pagos por 4.120 €. ¿Apruebas?».
La responsable de soporte de mi cliente, Marta, lo leyó a las 23:10, desde el sofá, y pulsó el botón verde. No pasó nada.
Bueno, esa noche no pasó nada. Al día siguiente pasó tres veces de más.
El human in the loop de aquel agente eran catorce líneas: una llamada a Slack y un await esperando la respuesta. En local funcionaba precioso. En producción, la función serverless se había muerto tres horas antes de que nadie pulsara nada, con la conversación entera en memoria.
El arreglo de urgencia fue relanzar el agente con «el usuario ya aprobó los reembolsos» metido en el prompt. El modelo volvió a planificar desde cero, esta vez encontró 41 pagos fallidos en vez de 38 — habían entrado tres nuevos por la noche — y reembolsó los 41.
El humano había aprobado 38. El agente ejecutó 41. Y nadie podía decir qué se aprobó exactamente, porque la propuesta solo había existido en la RAM de un proceso muerto.
Eso no es un bug. Es lo que pasa cuando tratas la aprobación humana como un if.
Qué es el human in the loop: la aprobación es asíncrona, tu loop no
El human in the loop (HITL) en un agente de IA es el patrón por el que el agente detiene su ejecución antes de una acción irreversible —mover dinero, enviar emails, borrar datos, desplegar— y solo continúa cuando una persona la aprueba, la edita o la rechaza. La diferencia entre que funcione y que no está en dónde ocurre esa pausa: no dentro del bucle, sino al final de una ejecución que termina y de otra que la retoma.
El agentic loop es una función: el modelo piensa, pide una herramienta, tú la ejecutas, le devuelves el resultado, repites. Todo dentro de la misma llamada, con el array de mensajes creciendo en memoria.
Meter una aprobación humana ahí parece trivial. Un if antes de ejecutar, una notificación, y esperas.
El problema es lo que hay al otro lado de esa espera: una persona. Y en lo que llevo medido, una persona tarda entre cuarenta segundos y dos días. Tu proceso no aguanta ni lo primero.
Se muere por el timeout de la función serverless. Se muere porque despliegas. Se muere por un reinicio del contenedor, por un OOM, porque el usuario cerró la pestaña y tu handler se canceló. Un await de seis horas no es una espera: es una apuesta a que nada se reinicie en seis horas.
Y en el caso improbable de que sobreviva, tienes un proceso vivo con la conversación completa en memoria, sin hacer absolutamente nada, ocupando RAM y una conexión abierta. Multiplícalo por doscientas aprobaciones pendientes un lunes por la mañana.
Hay un tercer problema, y es el que acaba en una reunión incómoda: si el estado vive en memoria, no existe. Nadie puede responder a «¿qué aprobó exactamente Marta el jueves a las 23:10?».
Así que la regla de diseño es esta: la aprobación humana no es una rama dentro del loop, es un final del loop. El agente termina su ejecución devolviendo un estado «esperando decisión». Una ejecución nueva y distinta, disparada por el webhook de aprobación, lo retoma donde se quedó.
Un punto de aprobación es una frontera de proceso. Todo lo demás sale de ahí.
Qué acciones necesitan aprobación humana: los tres niveles de riesgo
Antes de suspender nada hay que decidir qué merece suspenderse. Y aquí casi todo el mundo se pasa de frenada.
Un agente que pregunta cuarenta veces al día se desactiva solo. Peor todavía: no se desactiva, y el humano aprende a pulsar «Aprobar» sin leer. Un botón que se pulsa siempre no es un control, es un ritual.
Clasifica cada herramienta en tres niveles:
| Nivel | Ejemplos | ¿Aprobación? |
|---|---|---|
| Read-only | Buscar pedidos, leer un fichero, consultar saldo | Nunca. Ni una sola vez |
| Reversible | Crear un borrador, etiquetar, abrir un PR, escribir en staging | No, pero deja rastro y ten un deshacer |
| Irreversible | Reembolsar, enviar email, borrar registros, desplegar, publicar | Sí, salvo por debajo de un umbral |
El matiz que lo cambia todo: el riesgo no está en la herramienta, está en los argumentos. sendEmail a un destinatario es soporte normal; a doce mil, es una campaña que nadie autorizó. refundPayments de 3 € es ruido; de 4.120 € es una conversación.
Y el lote es una decisión, no treinta y ocho. Si tu tool reembolsa de uno en uno, el agente pedirá permiso treinta y ocho veces y habrás construido el autoclick con tus propias manos. La herramienta que se aprueba recibe el lote entero y el umbral se calcula sobre el total.
Por eso la política de aprobación es una función del input, no un booleano en la definición de la tool:
// tool-policy.ts
export type RiskLevel = "read_only" | "reversible" | "irreversible";
export type ApprovalMode = "auto" | "human";
export interface ToolPolicy<TInput> {
risk: RiskLevel;
approval: (input: TInput) => ApprovalMode;
}
const definePolicy = <TInput>(policy: ToolPolicy<TInput>): ToolPolicy<TInput> => policy;
export const toolPolicies = {
searchOrders: definePolicy<{ status: string }>({
risk: "read_only",
approval: () => "auto",
}),
draftRefundReport: definePolicy<{ orderIds: string[] }>({
risk: "reversible",
approval: () => "auto",
}),
refundPayments: definePolicy<{ orderIds: string[]; totalCents: number }>({
risk: "irreversible",
// El umbral mira el lote entero, no el pago suelto.
approval: ({ orderIds, totalCents }) =>
totalCents > 5_000 || orderIds.length > 1 ? "human" : "auto",
}),
sendEmail: definePolicy<{ to: string[]; subject: string }>({
risk: "irreversible",
approval: ({ to }) => (to.length > 1 ? "human" : "auto"),
}),
deleteCustomers: definePolicy<{ ids: string[] }>({
risk: "irreversible",
approval: () => "human",
}),
} as const;
export function approvalModeFor(toolName: string, input: unknown): ApprovalMode {
const policy = toolPolicies[toolName as keyof typeof toolPolicies];
// Fail-closed: una tool que no está en el mapa NO se ejecuta sola.
// El día que alguien añada una herramienta y olvide la política, el
// agente preguntará de más. Ese es el fallo barato.
if (!policy) return "human";
return (policy.approval as (input: unknown) => ApprovalMode)(input);
}
El cast de la última línea es el precio de tener un mapa heterogéneo. Lo pago en un único punto del sistema y no en cada herramienta, que es justo el reparto que quiero.
Los umbrales no son constantes de por vida. Empieza pidiendo aprobación de todo lo irreversible, y a las tres semanas, con el registro de decisiones delante, súbelos donde el humano lleve treinta aprobaciones seguidas sin rechazar ni una. Si un umbral nunca se ha movido, es que nadie está mirando.
Y si al terminar la clasificación resulta que todo requiere aprobación, no tienes un agente: tienes un formulario caro. Eso significa que le has dado herramientas que no debía tener, y el arreglo está antes, en el perímetro de ejecución, como conté en guardrails para agentes con acceso a terminal y base de datos.
Esta tabla, además, se escribe antes que el código. Qué es irreversible en tu dominio es una decisión de producto, no de implementación, y va en la spec junto al resto de reglas del sistema — es literalmente uno de los apartados que defiendo en el libro de Spec-Driven Development.
Cómo interrumpir y reanudar un agente de IA sin perder el estado
Ahora el núcleo. El loop tiene que poder terminar a mitad de un turno y volver a arrancar horas después como si nada.
Declaro las herramientas sin función de ejecución: el modelo puede pedirlas, pero quien decide si se ejecutan soy yo, en mi código. Ese es el punto de control.
// agent-run.ts
import { generateText, type ModelMessage, type JSONValue } from "ai";
import { approvalModeFor } from "./tool-policy";
import { buildPreview, type ApprovalPreview } from "./preview";
import { checkpoints } from "./checkpoints";
import { executeTool } from "./execute-tool";
// model, SYSTEM_PROMPT y toolSchemas salen de tu configuración del agente.
// executeTool: (call: PendingCall, opts?: { idempotencyKey?: string }) => Promise<JSONValue>
import { model, SYSTEM_PROMPT, toolSchemas } from "./config";
export interface PendingCall {
toolCallId: string;
toolName: string;
input: unknown;
}
export interface PendingApproval extends PendingCall {
approvalId: string;
preview: ApprovalPreview;
requestedAt: string;
expiresAt: string;
}
export type AgentOutcome =
| { status: "completed"; text: string }
| { status: "awaiting_approval"; runId: string; approval: PendingApproval }
| { status: "already_resolved" }
| { status: "exhausted"; stepsUsed: number };
const MAX_STEPS = 12;
// El shape exacto del resultado de tool depende de tu SDK.
// Este es el de AI SDK 5+; en 4.x era { type: "tool-result", result }.
export const toolResult = (call: PendingCall, value: JSONValue) => ({
type: "tool-result" as const,
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: "json" as const, value },
});
export async function runAgent(
runId: string,
initial: ModelMessage[],
stepsUsed = 0,
): Promise<AgentOutcome> {
const messages = [...initial];
for (let step = stepsUsed; step < MAX_STEPS; step++) {
const turn = await generateText({ model, system: SYSTEM_PROMPT, messages, tools: toolSchemas });
messages.push(...turn.response.messages);
if (turn.toolCalls.length === 0) return { status: "completed", text: turn.text };
const results: ReturnType<typeof toolResult>[] = [];
for (const [index, call] of turn.toolCalls.entries()) {
// En AI SDK 4.x los argumentos viajan en call.args, no en call.input
if (approvalModeFor(call.toolName, call.input) === "auto") {
results.push(toolResult(call, await executeTool(call)));
continue;
}
// Hay una llamada que necesita un humano. El turno se acaba aquí.
// Las llamadas que quedaban detrás NO se ejecutan, pero necesitan
// un resultado: el protocolo exige responder a todas las tool calls.
const deferred = turn.toolCalls.slice(index + 1).map((rest) =>
toolResult(rest, {
ok: false,
deferred: true,
instruction:
"No se ejecutó: el turno se detuvo esperando una aprobación humana. " +
"Si sigue siendo necesaria, vuelve a pedirla después.",
}),
);
const approval: PendingApproval = {
...call,
approvalId: crypto.randomUUID(),
preview: await buildPreview(call),
requestedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
};
await checkpoints.save({
runId,
messages,
partialResults: [...results, ...deferred],
approval,
stepsUsed: step + 1, // el presupuesto no se reinicia al reanudar
});
return { status: "awaiting_approval", runId, approval };
}
messages.push({ role: "tool", content: results });
}
return { status: "exhausted", stepsUsed: MAX_STEPS };
}
Fíjate en el bloque deferred, que es la parte que casi nadie ve venir. Cuando el modelo pide tres herramientas en el mismo turno y la segunda necesita aprobación, no puedes limitarte a guardar y salir: los proveedores exigen que cada tool call tenga su resultado antes de continuar la conversación. Si dejas una huérfana, al reanudar te comes un 400 y no entiendes por qué — el AI SDK tiene hasta un error con nombre propio para esto, MissingToolResultsError.
El checkpoint guarda tres cosas y las tres hacen falta: los mensajes hasta ese punto, los resultados parciales del turno a medias, y la llamada pendiente con su preview. Eso es todo el agente, serializado. En Postgres, en una tabla con run_id, approval_id, status y un jsonb.
El resto de la función que expone esto por HTTP es aburrido a propósito: si el status es awaiting_approval, mandas la notificación y devuelves un 202. La petición termina. El proceso puede morirse tranquilo. Y el presupuesto de pasos sigue siendo tuyo, exactamente igual que en el agentic loop en producción.
Esto es, con otros nombres, lo que hacen los frameworks. LangGraph lo llama interrupts: la función interrupt() congela el grafo, el checkpointer persiste el estado y se reanuda con new Command({ resume }) sobre el mismo thread_id.
El AI SDK trae tool approvals, y aquí hay que fijarse en dos cosas. La primera, dónde se declara: toolApproval no va dentro de la tool, va como opción de generateText, streamText o del ToolLoopAgent, en un mapa indexado por nombre de herramienta. Cada política recibe el input tipado y devuelve 'user-approval', undefined si no aplica, o un { type: 'denied', reason }. La segunda, la versión: esa API llegó en la v7 y no existe en la 5, que es contra la que está escrito el código de este post.
| Enfoque | Cómo se pausa | Dónde vive el estado | Qué te sigue tocando a ti |
|---|---|---|---|
| Propio (este post) | El loop devuelve awaiting_approval y la API un 202 |
Tabla agent_runs en Postgres, columna jsonb |
Todo, pero sin sorpresas |
| LangGraph | interrupt() congela el grafo en el nodo |
Checkpointer (memoria, Postgres, SQLite) | Caducidad, idempotencia y el mensaje de rechazo |
| AI SDK v7 | toolApproval deja la tool pendiente |
Lo persistes tú | Dónde guardas los mensajes y qué haces al reanudar |
Úsalos si te encajan — pero ninguno responde por ti dónde vive el checkpoint, quién limpia los que nadie aprobó y qué le cuentas al modelo cuando la respuesta es que no. Si prefieres modelar todo esto como estados explícitos, el enfoque de grafo de estados frente al loop clásico resuelve bastante bien la parte de la máquina de estados.
Los ejemplos de este post están escritos y verificados en septiembre de 2026 contra AI SDK 5 y LangGraph JS; si estás en AI SDK 4.x, los argumentos viajan en call.args y el resultado en result en vez de output.
Qué debe ver el humano: sin datos, la aprobación es una firma
«El agente quiere borrar 1.204 clientes. ¿Apruebas?» no es una pregunta. Es un trámite.
Nadie puede aprobar eso de verdad, porque no hay nada que evaluar. Y si no hay nada que evaluar, el humano no está decidiendo: está firmando.
El payload de aprobación tiene que llevar lo suficiente para decir que no:
// preview.ts
export interface ApprovalPreview {
title: string; // "Reembolsar 38 pagos — 4.120,00 €"
rationale: string; // por qué el agente cree que hay que hacerlo
impact: string[]; // efectos concretos, contados
payload: unknown; // exactamente lo que se va a ejecutar, sin resumir
sample: unknown[]; // 5 filas afectadas, para oler el error
reversible: boolean;
precondition: { name: string; value: string | number }; // se revalida al reanudar
}
Cuatro reglas, y la primera es innegociable.
El preview lo genera tu código, no el modelo. Si el resumen que lee el humano lo escribe el mismo LLM cuya acción estás supervisando, has montado un control donde el vigilado redacta el informe. Y si esa tool call llegó por una inyección de prompt en un email que el agente leyó, el resumen viene envenenado igual — el mismo problema que trato en tácticas defensivas contra inyección de prompts. El texto lo compone tu función a partir de los argumentos y de una consulta real a tu base de datos.
Números, no adverbios. «Varios registros» no se puede aprobar. «1.204 registros, de los cuales 6 tienen facturas emitidas este trimestre» se aprueba o se rechaza en cuatro segundos.
Una muestra. Cinco filas de las que van a cambiar. Es donde el humano detecta que el filtro estaba mal, y le cuesta una consulta barata a tu API.
Una precondición y una caducidad. Guarda el número que justificaba la acción — 38 pagos fallidos — y vuelve a comprobarlo al reanudar. Una aprobación de hace seis horas puede estar aprobando un mundo que ya no existe: si ahora hay 41, no ejecutas, vuelves a preguntar. Exactamente el fallo que le costó tres reembolsos de más a mi cliente.
Idempotencia: reanudar el agente sin ejecutar la acción dos veces
Persistir el estado abre la puerta al segundo problema del día: el botón se puede pulsar dos veces. Y se pulsa. El humano da doble clic, el webhook de Slack reintenta porque tu 200 tardó, alguien reenvía el enlace por WhatsApp.
Necesitas dos capas, porque cada una tapa un agujero distinto.
La primera es un compare-and-swap en la base de datos. No leas el checkpoint y luego lo actualices: reclámalo en una sola sentencia atómica.
UPDATE agent_runs
SET status = 39;resuming39;, resolved_at = now()
WHERE run_id = $1
AND approval_id = $2
AND status = 39;awaiting_approval39;
RETURNING checkpoint;
Si devuelve cero filas, alguien te ganó la carrera. No es un error: es el sistema funcionando. Devuelves un 200 idempotente y te callas.
Y ponle un timeout también al estado resuming: si el proceso se muere justo después de reclamar el checkpoint, esa fila se queda ahí para siempre y el job de caducidad, que solo mira awaiting_approval, no la va a rescatar nunca.
// resume.ts
export type Decision =
| { type: "approved"; approvedBy: string }
| { type: "approved_with_changes"; approvedBy: string; input: unknown }
| { type: "rejected"; approvedBy: string; reason: string };
export async function resumeRun(
runId: string,
approvalId: string,
decision: Decision,
): Promise<AgentOutcome> {
const claimed = await checkpoints.claim(runId, approvalId);
if (!claimed) return { status: "already_resolved" };
const { messages, partialResults, approval, stepsUsed } = claimed;
if (decision.type === "rejected") {
const denial = toolResult(approval, buildDenialResult(approval, decision));
return runAgent(runId, [...messages, { role: "tool", content: [...partialResults, denial] }], stepsUsed);
}
const input =
decision.type === "approved_with_changes" ? decision.input : approval.input;
// El payload lleva horas en la base de datos y vuelve a entrar por la puerta.
// Se valida otra vez contra el schema de la tool, como si viniera de fuera.
const parsed = toolSchemas[approval.toolName as keyof typeof toolSchemas].inputSchema.parse(input);
// La precondición que vio el humano tiene que seguir siendo verdad
const still = await checkPrecondition(approval.preview.precondition);
if (!still.ok) return requestApprovalAgain(runId, approval, still.current);
// idempotencyKey = approvalId: si esto se ejecuta dos veces, el proveedor
// devuelve el mismo resultado en lugar de mover el dinero otra vez
const output = await executeTool({ ...approval, input: parsed }, { idempotencyKey: approvalId });
const result = toolResult(approval, {
ok: true,
approvedBy: decision.approvedBy,
executedInput: parsed, // lo que se ejecutó de verdad, no lo que se pidió
data: output,
});
return runAgent(runId, [...messages, { role: "tool", content: [...partialResults, result] }], stepsUsed);
}
La segunda capa es la idempotencia aguas abajo. El compare-and-swap te protege del doble clic, pero no del proceso que se cae justo entre mover el dinero y escribir en la base de datos que lo movió. Para eso la acción tiene que ser idempotente en el otro extremo: la clave de idempotencia de Stripe, una restricción única en la tabla de efectos, un INSERT ... ON CONFLICT DO NOTHING. Usa el approvalId como clave y el reintento devuelve el mismo resultado en lugar de un segundo reembolso. Y no es casualidad que el expiresAt de la aprobación sean 24 horas: Stripe purga las claves de idempotencia a partir de las 24 horas de antigüedad, así que una aprobación que sobreviviera a esa ventana perdería justo la red que la protegía.
Y ojo con el parse de esa función, que parece decorativo y no lo es. Ese payload salió de tu proceso hace ocho horas, ha dormido en una base de datos y vuelve por un endpoint público. Tratarlo como dato de confianza porque «lo generamos nosotros» es el tipo de suposición que valido siempre en frontera, con el enfoque de schemas del curso de Zod para TypeScript.
Qué le devuelves al modelo cuando el humano dice que no
Aquí es donde se cae la mitad de las implementaciones que he revisado.
Devuelven esto:
{ "error": "denied" }
Y el modelo hace lo que hace un modelo ante una puerta cerrada: buscar otra. Reintenta con parámetros distintos, parte el borrado en dos llamadas de 600 registros, o directamente redacta una respuesta final diciendo que los reembolsos se han procesado correctamente. Un rechazo que parece un fallo técnico se lee como un fallo técnico.
El resultado de una tool es prompt. Escríbelo como tal:
{
"ok": false,
"approvalDenied": true,
"toolName": "refundPayments",
"deniedBy": "marta@cliente.com",
"reason": "12 de los 38 pagos son de un lote que ya se reembolsó manualmente el viernes.",
"instruction": "Un humano ha rechazado esta acción. No vuelvas a llamar a \"refundPayments\" en esta conversación, ni con otros parámetros, ni en lotes más pequeños, ni a través de otra herramienta. No intentes conseguir el mismo efecto por otra vía. Explica al usuario qué ibas a hacer, cuál fue el motivo del rechazo, y termina el turno sin ejecutar nada más."
}
Cuatro ingredientes y los cuatro son necesarios:
- Que fue una persona, no un error de red. Cambia por completo la interpretación.
- El motivo en lenguaje humano. Es información nueva y real que el modelo no tenía.
- La prohibición de buscar rutas alternativas, dicha de forma explícita. Sin esta línea, el modelo trocea la acción y lo vuelve a intentar.
- Qué hacer ahora. Terminar y reportar. Si no le das salida, se inventa una.
Y existe un tercer estado que casi nadie modela: aprobado con cambios. El humano no rechaza, edita — baja el reembolso a 26 pagos y aprueba. En ese caso, el resultado que devuelves debe llevar el executedInput real, porque si el modelo cree que se ejecutaron 38 va a escribirle al usuario que se reembolsaron 38. La verdad de lo que pasó viaja en el resultado de la tool o no viaja.
Añade también una línea al system prompt describiendo el protocolo: «si el resultado de una herramienta trae approvalDenied: true, esa acción está vetada para el resto de la conversación; sigue el campo instruction y no busques alternativas». El modelo obedece bastante bien cuando la instrucción es específica y llega en el momento en que toma la decisión.
Cómo implementar human in the loop hoy en tu agente
No montes el sistema entero. Coge la herramienta más peligrosa de tu agente — todos sabemos cuál es — y hazle tres cosas esta tarde.
Sácala del execute automático. Haz que tu loop, al llegar a ella, guarde los mensajes y la llamada pendiente en una tabla y devuelva un 202. Y escribe el resultado de rechazo como si fuera un prompt, porque lo es.
Con eso ya tienes lo importante: un agente que puede quedarse esperando sin estar vivo.
La idea de fondo es la misma que con cualquier mecanismo de defensa en un agente: si no le hablas al modelo, no lo estás controlando, solo lo estás frenando. Y un modelo frenado sin explicaciones busca la puerta de al lado.
Decidir todo esto antes de escribir el código — qué es irreversible, quién aprueba, qué pasa cuando la respuesta es no — es el método que enseño en el curso Construye con IA: de la idea al producto.
Y si prefieres verlo funcionando antes que leerlo, en Dominicode Labs estamos rodando estos checkpoints sobre proyectos reales, con la tabla de aprobaciones y el audit trail puestos.
Preguntas frecuentes
¿Qué es human in the loop en un agente de IA?
Human in the loop (HITL) es el patrón por el que un agente de IA detiene su ejecución antes de realizar una acción irreversible y solo continúa cuando una persona la aprueba, la edita o la rechaza. En una implementación correcta la pausa no es un await: el agente termina su ejecución guardando un checkpoint con los mensajes y la llamada pendiente, y una ejecución nueva, disparada por la decisión del humano, lo reanuda desde ahí. Aplica a mover dinero, enviar emails masivos, borrar registros, desplegar y publicar.
¿Puedo implementar human in the loop con un simple await hasta que el humano responda?
Solo si el humano contesta en segundos y tu proceso es de larga vida. En cuanto la aprobación puede tardar minutos, el await deja de ser una espera y pasa a ser una apuesta: un despliegue, un timeout de la función serverless o un reinicio del contenedor se llevan por delante todo el estado del agente. Además pagas RAM y una conexión abierta por cada aprobación pendiente. La alternativa correcta es terminar la ejecución, persistir el checkpoint y reanudar con una ejecución nueva.
¿Qué acciones de un agente deben requerir aprobación humana?
Las irreversibles, y dentro de ellas solo las que superan un umbral. Todo lo de solo lectura va sin aprobación siempre; lo reversible va sin aprobación pero con registro y con una forma de deshacer; lo irreversible — mover dinero, enviar emails, borrar, desplegar, publicar — pide permiso. El umbral se calcula sobre los argumentos, no sobre la herramienta: un reembolso de 3 € y uno de 4.000 € usan la misma tool y no tienen el mismo riesgo.
¿Dónde guardo el estado del agente mientras espera la aprobación?
En un almacén duradero fuera del proceso: una tabla en Postgres con run_id, approval_id, status y una columna jsonb con los mensajes, los resultados parciales del turno y la llamada pendiente. Redis sirve si tiene persistencia y si el TTL es mayor que el tiempo máximo de aprobación, pero para acciones que mueven dinero quieres una tabla auditable donde consultar meses después quién aprobó qué.
¿Qué pasa si nadie aprueba nunca la acción del agente?
Que se te llena la base de datos de agentes zombis, así que la caducidad forma parte del diseño. Pon un expiresAt en cada aprobación, y un job que cierre las vencidas devolviendo al modelo un resultado de expiración con la misma estructura que el rechazo — para que el agente pueda cerrar la conversación explicando qué quedó sin hacer. Si un tipo de aprobación caduca de forma sistemática, el problema no es el TTL: es que estás pidiendo permiso para algo que a nadie le importa.
¿Cómo evito que el agente ejecute dos veces si la aprobación llega duplicada?
Con dos capas. La primera es reclamar el checkpoint con un UPDATE ... WHERE status = 'awaiting_approval' RETURNING, en una sola sentencia atómica: si devuelve cero filas, otro ya lo reanudó y no ejecutas nada. La segunda es hacer idempotente la acción en el otro extremo, usando el approvalId como clave de idempotencia o como restricción única, porque el compare-and-swap no te salva si el proceso se cae justo después de mover el dinero y antes de registrarlo.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
