Aprobaciones humanas en agentes: lotes, timeout fail-closed y presupuesto de interrupciones
Resumen
Cómo pedir aprobación a un humano sin ahogarlo: lotes en vez de un ping por tool, timeout fail-closed (default-deny), presupuesto de interrupciones por run y payload con acción, blast radius y undo. Distinto de HITL (qué pausar) y de guardrails (qué bloquear en código). LangGraph interrupt() espera indefinido; el timeout lo pones tú.

Qué resuelve
Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.
La matriz de qué pausar vive en human-in-the-loop. Los guardrails cortan en código; esto es el cómo pedir: el humano ve un lote, no 14 pings; el silencio es no; y cada run tiene un techo de interrupciones. Si pides un sí por cada tool, nadie usa el agente. Si el timeout aprueba solo, un día se va un refund.
Los SDKs te dan la pausa, no la UX. LangGraph documenta que interrupt() espera indefinidamente hasta Command(resume=...). El OpenAI Agents SDK serializa RunState para que la decisión llegue horas después. El timeout fail-closed, el lote y el presupuesto de interrupciones los diseñas tú. Fuentes verificadas el 6 de septiembre de 2026.
Contrato: un lote por turno humano, timeout = rechazo, techo de interrupciones por run, payload con undo. Cero ping por tool. Cero default-allow.
Lo que el SDK no te escribe
LangGraph pausa el grafo, guarda checkpoint y espera. El payload de interrupt() es JSON-serializable y sale en stream.interrupts. Al reanudar, el nodo se re-ejecuta desde el principio: cualquier efecto antes del interrupt() corre otra vez. Por eso las docs piden side effects idempotentes o después de la pausa.
El Agents SDK marca tools con needs_approval=True o un callable. Si los args son JSON malformado, null, lista o NaN/Infinity, el callable no corre y la llamada pide aprobación manual (fail-closed). Las interrupciones viven en RunResult.interruptions; conviertes a RunState, llamas state.approve(...) / state.reject(...) y reanudas el mismo run de nivel superior, aunque la tool haya salido de un handoff o de Agent.as_tool().
Claude distingue client tools (las ejecutas tú: ahí va el gate) y server tools (las ejecuta Anthropic: no hay botón tuyo salvo no habilitarlas).
Ninguno de los tres te dice cómo se ve el Slack a las 23:00, cuántos botones caben en un celular ni qué pasa si el revisor se fue a dormir.
El payload: cinco campos, no un “¿ok?”
Un humano aprueba efecto, no un verbo. El mínimo que cabe en un lote:
| Campo | Para qué | Si falta |
|---|---|---|
| Acción + tool | Qué se va a ejecutar | Aprueban a ciegas |
| Argumentos (diff, no dump) | El artefacto real | “¿Corro migrate?” sin SQL |
| Blast radius | Quién / cuántos / qué ambiente | Un send masivo parece un draft |
| Undo | Reversible sí/no y cómo | Timeout se siente barato |
| Coste / techo | Tokens, Q, filas | Nadie calibra el umbral |
LangGraph permite devolver un dict (action, to, subject, body) y que el humano edite antes de reanudar. El SDK permite rejection_message por llamada para que el modelo sepa por qué no, no solo que no. Usa eso: “rechazado: destinatario fuera de allowlist”, no el texto genérico del SDK.
No pongas secretos en el payload. RunState.to_json() viaja con el contexto de la app; las docs avisan que RunContextWrapper.context se persiste. Tokens, cookies y PII no van al JSON de la cola.
Lotes, no un ping por tool
Si el modelo emite cuatro tools sensibles en el mismo turno, una tarjeta. El humano elige: aprobar 1 y 3, rechazar 2, editar 4. LangGraph documenta exactamente el mapa interrupt.id → resume cuando ramas paralelas pausan a la vez:
resume_map = {i.id: f"answer for {i.value}" for i in stream.interrupts}
graph.stream_events(Command(resume=resume_map), config, version="v3")
El Agents SDK es aún más explícito: no tienes que resolver todas las interruptions en el mismo pase. Las resueltas siguen; las pendientes vuelven a pausar. Eso es un lote, no un modal por tool.
Regla de producto: agrupa por blast radius, no por orden del modelo. Tres send_email al mismo dominio = un lote. Un refund + un deploy = dos tarjetas, nunca un “aprobar todo”.
El presupuesto de interrupciones evita el agente que se vuelve un ticketing interno. Pon un techo por run (ejemplo: 3 lotes o 8 tools). Al tocarlo, el run termina con rechazo y un log “budget exhausted”, no con el quinto ping. Un loop sin techo no es autonomía, es spam.

Timeout fail-closed: el silencio es no
interrupt() espera indefinidamente. RunState está hecho para durar: to_json() / from_json(), con versión del agente al lado porque tools y prompts cambian. Eso no es un sí. Es un puedes decidir mañana.
El timeout lo pone tu cola, no LangGraph:
- Al pausar, escribes el lote con
expires_at(15 min en horario, 4 h fuera, 24 h si es irreversible y hay undo). - Un worker (cron, queue delay) marca expirados.
- Reanudas con rechazo (
Command(resume={"action": "reject", "reason": "timeout"})ostate.reject(..., rejection_message="timeout")). - Dead letter + alerta. Cero ejecución.
Default-allow es el anti-patrón que convierte un humano ausente en un atacante lucky. Si el negocio exige “sí a las 02:00”, no es timeout: es un on-call con ack, otra persona, no un cron que pulsa el botón.
La ejecución durable entra aquí: el checkpoint es antes del efecto. El timeout rechaza; no re-ejecuta el cargo. Side effect después de interrupt(), o upsert idempotente antes.
Presupuesto de interrupciones
Cuenta tres números por tenant y por run:
| Métrica | Techo típico | Si se pasa |
|---|---|---|
| Lotes humanos / run | 3 | El run aborta; el modelo recibe “approval budget exhausted” |
| Tools en un lote | 8 | Parte el lote; no un scroll infinito |
| Interrupciones / humano / hora | 12 | Sube umbral automático o apaga tools sensibles |
El callable de needs_approval es el sitio para no preguntar: refund bajo umbral, send a allowlist, dry-run. Preguntar menos no es menos seguro; es el único modo de que el sí del humano valga. Sticky always_approve=True del SDK solo vale dentro del mismo run y por identidad de tool (server_label + nombre en Hosted MCP). No lo uses como política de tenant.
Anti-patrones de UX
- Un modal por tool call. El revisor aprueba por cansancio. Lote.
- Timeout = sí. El SDK espera; tú niegas.
- Payload de una línea. Sin diff, sin undo, sin blast radius, es un captcha.
- Aprobar “el agente”. HITL es por call id, no un permiso de sesión.
while True+interrupt()en el mismo nodo. LangGraph lo prohíbe: cada resume rejuega todas las iteraciones. Uninterrupt()por invocación; el loop va en edges condicionales.- Envolver
interrupt()en try/except o reordenar llamadas. Las docs: no. El runtime casa resumes por orden e id. - Efecto antes de la pausa. El nodo se re-ejecuta. Upsert o mueve el efecto después.
- Secretos en
RunState. El JSON de la cola no es tu bóveda.
Checklist
- Lista corta de tools con aprobación. El resto corre solo o lo corta un guardrail.
- Payload: acción, args/diff, blast radius, undo, coste.
- Un lote por turno humano; mapa
id → decisiónsi hay varias pausas. - Timeout escrito en la cola; worker reanuda con rechazo.
- Techo de lotes / run. Al tocarlo, abort + log, no el quinto ping.
- Checkpointer durable (
thread_id) oRunStateversionado. RAM no cuenta. - Side effects después de la pausa, o idempotentes.
- Rechazo con mensaje útil al modelo, no un silencio.
- Cero secretos en el JSON serializado.
FAQ
¿Esto reemplaza HITL? No. HITL decide qué pausar. Aquí decides cómo se pide. Enlázalas.
¿LangGraph tiene timeout de interrupt? No. Espera indefinido. El TTL es tuyo.
¿Puedo auto-aprobar en código? Sí: on_approval en Shell/ApplyPatch, on_approval_request en Hosted MCP, o el callable de needs_approval que devuelve false. Sigue siendo política, no un humano.
¿Y si el humano edita los args? LangGraph lo documenta: el resume puede traer to/subject/body nuevos. Valida otra vez. Editar no salta el schema.
Cuando el lote y el timeout están claros, el siguiente paso es meter el agente en un canal real: el curso de instalar un agente y el hub de seguridad, coste y operación.

Lecturas relacionadas
Sigue explorando AgentOps y otras piezas para builders.

SLO y error budget en agentes: 4 SLIs, burn rápido/lento y política 50/100

De error de producción a eval: el flywheel que alimenta el dataset

RAG en producción: permisos en la query, frescura e índices por tenant
