Guía10 min

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ú.

OpenAILangChainAnthropic
Cola de aprobaciones humanas con lote, timeout fail-closed y un presupuesto de interrupciones

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:

CampoPara quéSi falta
Acción + toolQué se va a ejecutarAprueban a ciegas
Argumentos (diff, no dump)El artefacto real“¿Corro migrate?” sin SQL
Blast radiusQuién / cuántos / qué ambienteUn send masivo parece un draft
UndoReversible sí/no y cómoTimeout se siente barato
Coste / techoTokens, Q, filasNadie 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.

Lote de aprobaciones con tres tools en una tarjeta y presupuesto de interrupciones

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:

  1. Al pausar, escribes el lote con expires_at (15 min en horario, 4 h fuera, 24 h si es irreversible y hay undo).
  2. Un worker (cron, queue delay) marca expirados.
  3. Reanudas con rechazo (Command(resume={"action": "reject", "reason": "timeout"}) o state.reject(..., rejection_message="timeout")).
  4. 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étricaTecho típicoSi se pasa
Lotes humanos / run3El run aborta; el modelo recibe “approval budget exhausted”
Tools en un lote8Parte el lote; no un scroll infinito
Interrupciones / humano / hora12Sube 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

  1. Un modal por tool call. El revisor aprueba por cansancio. Lote.
  2. Timeout = sí. El SDK espera; tú niegas.
  3. Payload de una línea. Sin diff, sin undo, sin blast radius, es un captcha.
  4. Aprobar “el agente”. HITL es por call id, no un permiso de sesión.
  5. while True + interrupt() en el mismo nodo. LangGraph lo prohíbe: cada resume rejuega todas las iteraciones. Un interrupt() por invocación; el loop va en edges condicionales.
  6. Envolver interrupt() en try/except o reordenar llamadas. Las docs: no. El runtime casa resumes por orden e id.
  7. Efecto antes de la pausa. El nodo se re-ejecuta. Upsert o mueve el efecto después.
  8. 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ón si 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) o RunState versionado. 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.

Timeout de aprobación que vence a rechazo y dead letter, no a un sí automático